Add live community data & telemetry opt-in UI (#5001)

This commit is contained in:
Michael Geers 2022-11-01 23:19:31 +01:00 • committed by GitHub
parent 5868036461
commit 09eab09415
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 579 additions and 193 deletions

View file

@ -308,3 +308,21 @@ small {
--bs-table-color: var(--evcc-default-text);
--bs-table-border-color: var(--evcc-gray);
}
.nav-tabs .nav-link {
color: var(--evcc-gray) !important;
}
.nav-tabs .nav-link.active {
color: var(--evcc-default-text) !important;
background-color: var(--evcc-box) !important;
border-bottom-color: var(--evcc-box);
}
.form-check-input:checked {
border-color: var(--bs-primary);
background-color: var(--bs-primary);
}
.dark .form-switch .form-check-input:checked {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2328293e'/%3e%3c/svg%3e");
}

View file

@ -26,10 +26,11 @@ modalUpdateStatusStart = "Aktualisierung gestartet: "
modalUpdateStatusFailed = "Aktualisierung nicht möglich: "
[footer.savings]
tabTitle = "Meine Daten"
footerShort = "{percent}% Sonne"
footerLong = "{percent}% Sonnenenergie"
modalTitle = "Auswertung Ladeenergie"
since = "Seit {since}."
since = "seit {since}"
percentTitle = "Sonnenenergie"
percentSelf = "{self} kWh Sonne"
percentGrid = "{grid} kWh Netz"
@ -40,8 +41,28 @@ savingsTitle = "Ersparnis"
savingsComparedToGrid = "gegenüber Netzbezug"
savingsTotalEnergy = "{total} kWh geladen"
[footer.community]
tabTitle = "Live-Community"
power = "Ladeleistung"
powerSub1 = "{activeClients} von {totalClients} Nutzern"
powerSub2 = "laden mit evcc"
greenShare = "Sonnenanteil"
greenShareSub1 = "der Leistung kommt"
greenShareSub2 = "aus PV & Hausakku"
greenEnergy = "Sonnenenergie"
greenEnergySub1 = "seit Oktober 2022"
greenEnergySub2 = "über evcc geladen"
[footer.telemetry]
optIn = "Ich möchte meine Daten auch beisteuern."
optInMoreDetails = "Mehr Details gibts {0}."
optInMoreDetailsLink = "hier"
optInSponsorship = "Sponsoring erforderlich."
[footer.sponsor]
thanks = "Danke für deine Unterstützung, {sponsor}! Das hilft uns bei der Weiterentwicklung."
titleSponsor = "Du bist Unterstützer"
titleNoSponsor = "Unterstütze uns"
thanks = "Danke für dein Sponsoring, {sponsor}! Das hilft uns bei der Weiterentwicklung."
confetti = "Lust auf Konfetti?"
supportUs = "Unsere Mission: Sonne tanken zum Standard machen. Hilf uns und unterstütze evcc finanziell."
sticker = "...oder evcc Sticker?"

View file

@ -26,10 +26,11 @@ modalUpdateStatusStart = "Update started: "
modalUpdateStatusFailed = "Update failed: "
[footer.savings]
tabTitle = "My data"
footerShort = "{percent}% solar"
footerLong = "{percent}% solar energy"
modalTitle = "Charge Energy Overview"
since = "Since {since}."
since = "since {since}"
percentTitle = "Solar Energy"
percentSelf = "{self} kWh solar"
percentGrid = "{grid} kWh grid"
@ -40,7 +41,27 @@ savingsTitle = "Savings"
savingsComparedToGrid = "compared to grid"
savingsTotalEnergy = "{total} kWh charged"
[footer.community]
tabTitle = "Live community"
power = "Charging power"
powerSub1 = "{activeClients} of {totalClients} participants"
powerSub2 = "are charging right now"
greenShare = "Solar share"
greenShareSub1 = "power is covered by"
greenShareSub2 = "PV & battery storage"
greenEnergy = "Solar energy"
greenEnergySub1 = "charged with evcc"
greenEnergySub2 = "since October 2022"
[footer.telemetry]
optIn = "I want to contribute my data as well."
optInMoreDetails = "More details are available {0}."
optInMoreDetailsLink = "here"
optInSponsorship = "Sponsoring required."
[footer.sponsor]
titleSponsor = "You are a supporter"
titleNoSponsor = "Support us"
thanks = "Thanks for your support, {sponsor}! It helps us with the further development."
confetti = "Ready for confetti?"
supportUs = "Our mission: Make solar charging the standard. Help us and support evcc financially."

View file

@ -26,13 +26,14 @@ modalUpdateStatusStart = "Naujinimas prasidėjo: "
modalUpdateStatusFailed = "Naujinimas nepavyko: "
[footer.savings]
tabTitle = "Mano duomenys"
footerShort = "{percent}% saulės"
footerLong = "{percent}% saulės energija"
modalTitle = "Įkrovimo energijos apžvalga"
since = "Nuo {since}."
since = "nuo {since}"
percentTitle = "Saulės energija"
percentSelf = "{self} kWh saulės"
percentGrid = "{grid} kWh tinklas"
percentGrid = "{grid} kWh tinklo"
priceTitle = "Energijos kaina"
priceFeedIn = "{feedInPrice} eksporto"
priceGrid = "{gridPrice} tinklo"
@ -40,7 +41,27 @@ savingsTitle = "Sutaupyta"
savingsComparedToGrid = "palyginus su tinklu"
savingsTotalEnergy = "{total} kWh įkrauta"
[footer.community]
tabTitle = "Bendruomenės duomenys"
power = "Įkrovimo galia"
powerSub1 = "{activeClients} iš {totalClients} dalyvių"
powerSub2 = "įkrauna šiuo metu"
greenShare = "Saulės dalis"
greenShareSub1 = "galios tieka Saulė ir"
greenShareSub2 = "energijos kaupikliai"
greenEnergy = "Saulės energija"
greenEnergySub1 = "įkrauta su evcc"
greenEnergySub2 = "nuo 2022 Spalio"
[footer.telemetry]
optIn = "Noriu prisidėti savo duomenimis."
optInMoreDetails = "Daugiau informacijos rasite {0}."
optInMoreDetailsLink = "čia"
optInSponsorship = "(Gali tik rėmėjai.)"
[footer.sponsor]
titleSponsor = "Esate rėmėjais"
titleNoSponsor = "Paremkite mus"
thanks = "Ačiū, kad mus remiate {sponsor}! Taip prisidedate prie projekto vystymo."
confetti = "Norite konfeti?"
supportUs = "Mūsų misija: Siekiame, kad įkrovimas saulės energija taptų standartu. Padėkite mums ir paremkite evcc finansiškai."

21
assets/js/communityApi.js Normal file
View file

@ -0,0 +1,21 @@
import axios from "axios";
const api = axios.create({
baseURL: "https://api.evcc.io/v1/",
headers: {
Accept: "application/json",
},
});
// global error handling
api.interceptors.response.use(
(response) => response,
(error) => {
const url = error.config.baseURL + error.config.url;
const message = `${error.message}: API request failed ${url}`;
window.app.error({ message });
return Promise.reject(error);
}
);
export default api;

View file

@ -11,18 +11,19 @@ export default {
to: { type: Number },
format: { type: Function },
noAnimation: { type: Boolean },
duration: { type: Number, default: DURATION },
},
data() {
return { duration: 0 };
return { activeDuration: 0 };
},
computed: {
animationDuration() {
return this.noAnimation ? 0 : this.duration;
return this.noAnimation ? 0 : this.activeDuration;
},
},
watch: {
to: function () {
this.duration = DURATION;
this.activeDuration = this.duration;
},
},
};

View file

@ -0,0 +1,106 @@
<template>
<div class="d-block d-lg-flex mb-2 justify-content-between">
<SavingsTile
class="text-accent2"
icon="car"
:title="$t('footer.community.power')"
:value="chargePower"
:valueFmt="numberAnimationFmt"
:animationDuration="animationDuration"
unit="kW"
:sub1="$t('footer.community.powerSub1', { totalClients, activeClients })"
:sub2="$t('footer.community.powerSub2')"
/>
<SavingsTile
class="text-accent1"
icon="sun"
:title="$t('footer.community.greenShare')"
:value="greenShare"
:valueFmt="numberAnimationFmt"
:animationDuration="animationDuration"
unit="%"
:sub1="$t('footer.community.greenShareSub1')"
:sub2="$t('footer.community.greenShareSub2')"
/>
<SavingsTile
class="text-accent3"
icon="eco"
:title="$t('footer.community.greenEnergy')"
:value="greenEnergyMWh"
:valueFmt="numberAnimationFmt"
:animationDuration="animationDuration"
unit="MWh"
:sub1="$t('footer.community.greenEnergySub1')"
:sub2="$t('footer.community.greenEnergySub2')"
/>
</div>
</template>
<script>
import SavingsTile from "./SavingsTile.vue";
import formatter from "../mixins/formatter";
import communityApi from "../communityApi";
export default {
name: "LiveCommunity",
components: { SavingsTile },
mixins: [formatter],
props: {},
data() {
return {
refresh: null,
result: {},
animationDuration: 0.5,
};
},
computed: {
totalClients() {
return this.result.totalClients;
},
activeClients() {
return this.result.activeClients;
},
chargePower() {
const { chargePower = 0 } = this.result;
return chargePower / 1e3;
},
greenShare() {
const { chargePower, greenPower } = this.result;
if (!chargePower) {
return 0;
}
return (100 / chargePower) * greenPower;
},
greenEnergyMWh() {
const { greenEnergy = 0 } = this.result;
return greenEnergy / 1e3;
},
},
async mounted() {
this.refresh = setInterval(this.update, 5000);
await this.update();
this.$nextTick(() => {
this.animationDuration = 5;
});
},
unmounted() {
clearInterval(this.refresh);
},
methods: {
async update() {
try {
const response = await communityApi.get("total");
this.result = response.data || {};
} catch (err) {
console.error(err);
}
},
numberAnimationFmt(number) {
return this.fmtNumber(number, 1);
},
},
};
</script>

View file

@ -41,64 +41,98 @@
></button>
</div>
<div class="modal-body">
<p>
{{
$t("footer.savings.since", {
since: fmtTimeAgo(secondsSinceStart()),
})
}}
</p>
<ul class="nav nav-tabs">
<li class="nav-item">
<a
class="nav-link"
:class="{ active: !communityView }"
href="#"
@click.prevent="showMyData"
>
{{ $t("footer.savings.tabTitle") }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: communityView }"
href="#"
@click.prevent="showCommunity"
>
{{ $t("footer.community.tabTitle") }}
</a>
</li>
</ul>
<div class="d-block d-lg-flex mb-4">
<SavingsTile
class="text-accent1"
icon="sun"
:title="$t('footer.savings.percentTitle')"
:value="percent"
unit="%"
:sub1="
$t('footer.savings.percentSelf', {
self: fmtKw(selfConsumptionCharged * 1000, true, false),
})
"
:sub2="
$t('footer.savings.percentGrid', {
grid: fmtKw(gridCharged * 1000, true, false),
})
"
/>
<div v-if="!communityView" class="my-4">
<div class="d-block d-lg-flex mb-2 justify-content-between">
<SavingsTile
class="text-accent1"
icon="sun"
:title="$t('footer.savings.percentTitle')"
:value="percent"
unit="%"
:sub1="
$t('footer.savings.percentSelf', {
self: fmtKw(
selfConsumptionCharged * 1000,
true,
false
),
})
"
:sub2="
$t('footer.savings.percentGrid', {
grid: fmtKw(gridCharged * 1000, true, false),
})
"
/>
<SavingsTile
class="text-accent2"
icon="receivepayment"
:title="$t('footer.savings.priceTitle')"
:value="effectivePriceFormatted.value"
:unit="effectivePriceFormatted.unit"
:sub1="
$t('footer.savings.priceFeedIn', {
feedInPrice: fmtPricePerKWh(feedInPrice, currency),
})
"
:sub2="
$t('footer.savings.priceGrid', {
gridPrice: fmtPricePerKWh(gridPrice, currency),
})
"
/>
<SavingsTile
class="text-accent2"
icon="receivepayment"
:title="$t('footer.savings.priceTitle')"
:value="effectivePriceFormatted.value"
:unit="effectivePriceFormatted.unit"
:sub1="
$t('footer.savings.priceFeedIn', {
feedInPrice: fmtPricePerKWh(feedInPrice, currency),
})
"
:sub2="
$t('footer.savings.priceGrid', {
gridPrice: fmtPricePerKWh(gridPrice, currency),
})
"
/>
<SavingsTile
class="text-accent3"
icon="coinjar"
:title="$t('footer.savings.savingsTitle')"
:value="fmtMoney(amount, currency)"
:unit="fmtCurrencySymbol(currency)"
:sub1="$t('footer.savings.savingsComparedToGrid')"
:sub2="
$t('footer.savings.savingsTotalEnergy', {
total: fmtKw(totalCharged * 1000, true, false),
})
"
/>
<SavingsTile
class="text-accent3"
icon="coinjar"
:title="$t('footer.savings.savingsTitle')"
:value="fmtMoney(amount, currency)"
:unit="fmtCurrencySymbol(currency)"
:sub1="$t('footer.savings.savingsComparedToGrid')"
:sub2="
$t('footer.savings.savingsTotalEnergy', {
total: fmtKw(totalCharged * 1000, true, false),
})
"
/>
</div>
<p class="my-3 lh-2">
<small>
{{
$t("footer.savings.since", {
since: fmtDayMonthYear(startDate),
})
}}
</small>
</p>
</div>
<div v-else class="my-4">
<LiveCommunity />
<TelemetrySettings :sponsor="sponsor" />
</div>
<Sponsor :sponsor="sponsor" />
@ -114,10 +148,12 @@
import formatter from "../mixins/formatter";
import Sponsor from "./Sponsor.vue";
import SavingsTile from "./SavingsTile.vue";
import LiveCommunity from "./LiveCommunity.vue";
import TelemetrySettings from "./TelemetrySettings.vue";
export default {
name: "Savings",
components: { Sponsor, SavingsTile },
components: { Sponsor, SavingsTile, LiveCommunity, TelemetrySettings },
mixins: [formatter],
props: {
selfConsumptionPercent: Number,
@ -132,6 +168,9 @@ export default {
feedInPrice: { type: Number },
currency: String,
},
data() {
return { communityView: false, telemetryEnabled: false };
},
computed: {
percent() {
return Math.round(this.selfConsumptionPercent) || 0;
@ -142,35 +181,17 @@ export default {
);
return { value, unit };
},
startDate() {
return new Date(this.since * 1000);
},
},
methods: {
secondsSinceStart() {
return this.since * 1000 - Date.now();
showCommunity() {
this.communityView = true;
},
showMyData() {
this.communityView = false;
},
},
};
</script>
<style scoped>
.chart {
height: 2.5rem;
}
.chart-item--self {
background-color: var(--evcc-self);
}
.chart-item--grid {
background-color: var(--evcc-grid);
}
.chart-item--no-data {
background-color: var(--bs-gray-medium);
}
.chart-item {
transition-property: width;
transition-duration: var(--evcc-transition-medium);
transition-timing-function: linear;
}
.tile-icon {
width: 70px;
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<div class="flex-grow-1 d-flex mb-4 mb-lg-0 align-items-center align-items-lg-start">
<div class="flex-shrink-1 d-flex mb-4 mb-lg-0 align-items-center align-items-lg-start">
<shopicon-regular-sun v-if="icon === 'sun'" class="tile-icon"></shopicon-regular-sun>
<shopicon-regular-coinjar
v-if="icon === 'coinjar'"
@ -9,17 +9,25 @@
v-if="icon === 'receivepayment'"
class="tile-icon"
></shopicon-regular-receivepayment>
<shopicon-regular-car3 v-if="icon === 'car'" class="tile-icon"></shopicon-regular-car3>
<shopicon-regular-eco1 v-if="icon === 'eco'" class="tile-icon"></shopicon-regular-eco1>
<div class="ms-3 d-flex flex-grow-1 d-lg-block align-items-center justify-content-between">
<div>
<p class="my-0 fw-bold">{{ title }}</p>
<p class="my-0 fw-bold text-truncate">{{ title }}</p>
<strong class="d-flex align-items-baseline lh-sm">
<span class="fs-1">{{ value }}</span>
<span class="fs-1">
<AnimatedNumber
v-if="valueFmt"
:to="value"
:format="valueFmt"
:duration="animationDuration"
/>
<span v-else>{{ value }}</span>
</span>
<span class="ms-1 unit">{{ unit }}</span>
</strong>
</div>
<small
class="d-block mt-0 ms-3 ms-lg-0 text-end text-lg-start flex-grow-1 flex-shrink-1"
>
<small class="d-block mt-0 ms-3 ms-lg-0 text-end text-lg-start">
{{ sub1 }} <br />
{{ sub2 }}
</small>
@ -31,13 +39,19 @@
import "@h2d2/shopicons/es/regular/coinjar";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/car3";
import "@h2d2/shopicons/es/regular/eco1";
import AnimatedNumber from "./AnimatedNumber.vue";
export default {
name: "SavingsTile",
components: { AnimatedNumber },
props: {
title: String,
icon: String,
value: [String, Number],
valueFmt: Function,
animationDuration: Number,
unit: String,
sub1: String,
sub2: String,

View file

@ -1,8 +1,12 @@
<template>
<div v-if="sponsor">
<p class="mb-3">
{{ $t("footer.sponsor.thanks", { sponsor }) }}
<p class="fw-bold mb-1 d-flex">
<shopicon-regular-heart
class="title-icon text-primary d-inline-block me-1"
></shopicon-regular-heart>
{{ $t("footer.sponsor.titleSponsor") }}
</p>
<p class="mb-3">{{ $t("footer.sponsor.thanks", { sponsor }) }}</p>
<div
class="d-flex justify-content-center align-items-center flex-column flex-lg-row align-items-lg-baseline justify-content-lg-start"
>
@ -20,9 +24,10 @@
</div>
</div>
<div v-else>
<p class="mb-3">
{{ $t("footer.sponsor.supportUs") }}
<p class="fw-bold mb-1">
{{ $t("footer.sponsor.titleNoSponsor") }}
</p>
<p class="mb-3">{{ $t("footer.sponsor.supportUs") }}</p>
<div
class="d-flex justify-content-center align-items-center flex-column flex-lg-row align-items-lg-baseline justify-content-lg-start"
>
@ -89,6 +94,9 @@ export default {
</script>
<style scoped>
.title-icon {
transform: translateY(-2px);
}
.confetti-button {
/* prevent double-tap zoom */
touch-action: none;

View file

@ -0,0 +1,64 @@
<template>
<div class="form-check form-switch my-3">
<input
id="telemetryEnabled"
:checked="enabled"
class="form-check-input"
type="checkbox"
role="switch"
:disabled="!sponsor"
@change="change"
/>
<label class="form-check-label" for="telemetryEnabled">
{{ $t("footer.telemetry.optIn") }}
<i18n-t v-if="sponsor" tag="span" keypath="footer.telemetry.optInMoreDetails">
<a href="https://docs.evcc.io/docs/guides/setup/#telemetry--community-daten">
{{ $t("footer.telemetry.optInMoreDetailsLink") }}
</a>
</i18n-t>
<span v-else>{{ $t("footer.telemetry.optInSponsorship") }}</span>
</label>
</div>
</template>
<script>
import api from "../api";
export default {
name: "TelemetrySettings",
props: { sponsor: String },
data() {
return {
enabled: null,
};
},
async mounted() {
await this.update();
},
methods: {
async change(e) {
try {
const response = await api.post(`settings/telemetry/${e.target.checked}`);
this.enabled = response.data.result;
} catch (err) {
window.app.error({
message: `Unable to update telemetry setting: ${err.message}`,
});
}
},
async update() {
try {
const response = await api.get("settings/telemetry");
this.enabled = response.data.result;
} catch (err) {
console.error(err);
}
},
},
};
</script>
<style scoped>
.form-check {
min-height: inherit !important;
}
</style>

View file

@ -33,6 +33,13 @@ export default {
fmtKWh: function (val) {
return this.fmtKw(val) + "h";
},
fmtNumber: function (number, decimals) {
return new Intl.NumberFormat(this.$i18n.locale, {
style: "decimal",
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(number);
},
fmtUnit: function (val) {
return Math.abs(val) >= this.fmtLimit ? "k" : "";
},
@ -139,6 +146,13 @@ export default {
year: "numeric",
}).format(date);
},
fmtDayMonthYear: function (date) {
return new Intl.DateTimeFormat(this.$i18n.locale, {
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
},
fmtMoney: function (amout = 0, currency = "EUR") {
return new Intl.NumberFormat(this.$i18n.locale, {
style: "currency",

View file

@ -2,8 +2,7 @@ network:
port: 7070
log: debug
levels:
mercedes: trace
plant: 683fd96cb7de8cf3080e1d79a12ffedd03d66d7c39be742350559817833ae6f7
interval: 3s

View file

@ -94,8 +94,14 @@ func configureEnvironment(cmd *cobra.Command, conf config) (err error) {
}
// setup telemetry
if err == nil {
err = telemetry.Create(conf.Plant)
}
// deprecated: enable telemetry if its in the config
if err == nil && conf.Telemetry {
err = telemetry.Create(sponsor.Token, conf.Plant)
log.WARN.Println("Enabling telementry in config is deprecated. Use UI instead.")
err = telemetry.Enable(true)
}
// setup mqtt client listener

File diff suppressed because one or more lines are too long

6
dist/assets/index.c2a568b4.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

70
dist/assets/index.fc0aa81e.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -20,8 +20,8 @@
<meta name="theme-color" content="#020318" />
<title>evcc</title>
<script type="module" crossorigin src="./assets/index.714c7ce4.js"></script>
<link rel="stylesheet" href="./assets/index.f831c606.css">
<script type="module" crossorigin src="./assets/index.fc0aa81e.js"></script>
<link rel="stylesheet" href="./assets/index.c2a568b4.css">
</head>
<body>

16
package-lock.json generated
View file

@ -11,7 +11,7 @@
"@popperjs/core": "^2.11.5",
"@vitejs/plugin-vue": "^3.0.1",
"axios": "^0.27.2",
"bootstrap": "^5.1.3",
"bootstrap": "^5.2.2",
"canvas-confetti": "^1.4.0",
"smoothscroll-polyfill": "^0.4.4",
"vue": "^3.2.29",
@ -17098,9 +17098,9 @@
"dev": true
},
"node_modules/bootstrap": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.0.tgz",
"integrity": "sha512-qlnS9GL6YZE6Wnef46GxGv1UpGGzAwO0aPL1yOjzDIJpeApeMvqV24iL+pjr2kU4dduoBA9fINKWKgMToobx9A==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.2.tgz",
"integrity": "sha512-dEtzMTV71n6Fhmbg4fYJzQsw1N29hJKO1js5ackCgIpDcGid2ETMGC6zwSYw09v05Y+oRdQ9loC54zB1La3hHQ==",
"funding": [
{
"type": "github",
@ -17112,7 +17112,7 @@
}
],
"peerDependencies": {
"@popperjs/core": "^2.11.5"
"@popperjs/core": "^2.11.6"
}
},
"node_modules/boxen": {
@ -45052,9 +45052,9 @@
"dev": true
},
"bootstrap": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.0.tgz",
"integrity": "sha512-qlnS9GL6YZE6Wnef46GxGv1UpGGzAwO0aPL1yOjzDIJpeApeMvqV24iL+pjr2kU4dduoBA9fINKWKgMToobx9A==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.2.tgz",
"integrity": "sha512-dEtzMTV71n6Fhmbg4fYJzQsw1N29hJKO1js5ackCgIpDcGid2ETMGC6zwSYw09v05Y+oRdQ9loC54zB1La3hHQ==",
"requires": {}
},
"boxen": {

View file

@ -16,7 +16,7 @@
"@popperjs/core": "^2.11.5",
"@vitejs/plugin-vue": "^3.0.1",
"axios": "^0.27.2",
"bootstrap": "^5.1.3",
"bootstrap": "^5.2.2",
"canvas-confetti": "^1.4.0",
"smoothscroll-polyfill": "^0.4.4",
"vue": "^3.2.29",

View file

@ -75,6 +75,10 @@ func SetJson(key string, val any) error {
return err
}
func SetBool(key string, val bool) {
SetString(key, strconv.FormatBool(val))
}
func String(key string) (string, error) {
idx := slices.IndexFunc(settings, func(s setting) bool {
return s.Key == key
@ -109,6 +113,14 @@ func Time(key string) (time.Time, error) {
return time.Parse(time.RFC3339, s)
}
func Bool(key string) (bool, error) {
s, err := String(key)
if err != nil {
return false, err
}
return strconv.ParseBool(s)
}
func Json(key string, res any) error {
s, err := String(key)
if err == nil {

View file

@ -8,6 +8,7 @@ import (
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/telemetry"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
)
@ -98,6 +99,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
"prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoC, site.GetPrioritySoC)},
"residualpower": {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
"sessions": {[]string{"GET"}, "/sessions", sessionHandler},
"telemetry": {[]string{"GET"}, "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
"telemetry2": {[]string{"POST", "OPTIONS"}, "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
}
for _, r := range routes {

View file

@ -140,6 +140,34 @@ func intHandler(set func(int) error, get func() int) http.HandlerFunc {
}
}
// boolHandler updates bool-param api
func boolHandler(set func(bool) error, get func() bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
val, err := strconv.ParseBool(vars["value"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
err = set(val)
if err != nil {
jsonError(w, http.StatusNotAcceptable, err)
return
}
jsonResult(w, get())
}
}
// boolGetHandler retrievs bool api values
func boolGetHandler(get func() bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
jsonResult(w, get())
}
}
// stateHandler returns current charge mode
func stateHandler(cache *util.Cache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

View file

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/machine"
"github.com/evcc-io/evcc/util/request"
@ -16,14 +17,21 @@ const api = "https://api.evcc.io"
var instanceID string
func Enabled() bool {
return instanceID != ""
enabled, _ := settings.Bool("telemetry.enabled")
return enabled && sponsor.IsAuthorized()
}
func Create(token, machineID string) error {
if token == "" {
func Enable(enable bool) error {
if enable && !sponsor.IsAuthorized() {
return errors.New("telemetry requires sponsorship")
}
settings.SetBool("telemetry.enabled", enable)
// TODO: remove once settings has central persistance mechanism
err := settings.Persist()
return err
}
func Create(machineID string) error {
if machineID == "" {
var err error
if machineID, err = machine.ProtectedID("evcc-api"); err != nil {