feat: Energiediagramm adaptieren
Tests / test (push) Successful in 51s

This commit is contained in:
dh
2026-09-27 08:48:59 +00:00
parent 75650c0621
commit db704dd011
16 changed files with 1576 additions and 30 deletions
+56
View File
@@ -0,0 +1,56 @@
# Energiediagramm
Das Energiediagramm stellt Produktion, Hausverbrauch, Netzbezug und
Einspeisung aus archivierten Energiezaehlern dar. Zwei Kreisdiagramme zeigen
Eigenverbrauchsquote und Autarkiegrad.
## Voraussetzungen
- IP-Symcon ab Version 8.0
- aktiviertes Archiv
- fortlaufende Integer- oder Floatzaehler
- Zaehlerwerte in kWh oder ein passender gemeinsamer Zaehlerfaktor
## Konfiguration
Produktion, Einspeisung und Netzbezug sind Pflichtquellen. Ein vorhandener
Hausverbrauchszaehler kann optional direkt verwendet werden. Ohne diese Quelle
wird der Hausverbrauch als Produktion minus Einspeisung plus Netzbezug
berechnet.
Der Standardzeitraum kann auf aktuellen Tag, aktuelle Woche, aktuellen Monat,
aktuelles Jahr oder Gesamt gesetzt werden. In der Visualisierung stehen
zusaetzlich ein frei waehlbarer inklusiver Datumsbereich und die Navigation
durch abgeschlossene Zeitraeume zur Verfuegung.
Die Farben fuer Eigenverbrauchsquote und Autarkiegrad werden mit den beiden
Farbauswahlen in der Instanzkonfiguration festgelegt.
## Darstellung
Die Kachel und ihre vergroesserte Ansicht erhalten beim Aufbau sofort einen
vollstaendigen Datenstand. Beim Oeffnen und beim erneuten Sichtbarwerden wird
die Darstellung aktualisiert. Das Diagrammraster wechselt bei geringer Breite
automatisch von zwei Spalten auf eine Spalte.
## Berechnung
- Hausverbrauch: direkter Zaehler oder Produktion - Einspeisung + Netzbezug
- Eigenverbrauch: Hausverbrauch - Netzbezug, begrenzt auf die Produktion
- Eigenverbrauchsquote: Eigenverbrauch / Produktion
- Autarkiegrad: Eigenverbrauch / Hausverbrauch
Negative Zaehlerdifferenzen, etwa nach einem Zaehlerwechsel, werden fuer den
betroffenen Zeitraum auf 0 kWh begrenzt.
## Migration aus Enelix 1
Uebernommen wurden die drei Pflichtzaehler, die Zeitraumsauswahl, die
Datumsnavigation, die Energiebilanz und beide Kreisdiagramme. Neu sind die
optionale direkte Hausverbrauchsquelle, der konfigurierbare Standardzeitraum,
der freie Datumsbereich, waehlbare Farben, eingebettete Initialdaten fuer die
vergroesserte Ansicht und das responsive Ein-/Zweispaltenlayout.
Das Enelix-1-Modul wird nicht in-place aktualisiert. Es muss eine neue
Energiediagramm-Instanz angelegt und mit den bisherigen Zaehlern konfiguriert
werden.
+97
View File
@@ -0,0 +1,97 @@
{
"elements": [
{
"type": "Label",
"caption": "Energiezaehler muessen als fortlaufende kWh-Zaehler im Archiv protokolliert werden."
},
{
"type": "SelectVariable",
"name": "ProduktionZaehlerID",
"caption": "Produktion"
},
{
"type": "SelectVariable",
"name": "EinspeisungZaehlerID",
"caption": "Einspeisung"
},
{
"type": "SelectVariable",
"name": "NetzbezugZaehlerID",
"caption": "Netzbezug"
},
{
"type": "SelectVariable",
"name": "VerbrauchZaehlerID",
"caption": "Hausverbrauch (optional)"
},
{
"type": "NumberSpinner",
"name": "Zaehlerfaktor",
"caption": "Zaehlerfaktor auf kWh",
"minimum": 0.000001,
"maximum": 1000000,
"digits": 6
},
{
"type": "Select",
"name": "Standardzeitraum",
"caption": "Standardzeitraum",
"options": [
{ "caption": "Aktueller Tag", "value": "day" },
{ "caption": "Aktuelle Woche", "value": "week" },
{ "caption": "Aktueller Monat", "value": "month" },
{ "caption": "Aktuelles Jahr", "value": "year" },
{ "caption": "Gesamt", "value": "total" }
]
},
{
"type": "NumberSpinner",
"name": "Aktualisierungsintervall",
"caption": "Aktualisierungsintervall",
"minimum": 10,
"maximum": 3600,
"suffix": " Sekunden"
},
{
"type": "RowLayout",
"items": [
{
"type": "SelectColor",
"name": "FarbeEigenverbrauch",
"caption": "Eigenverbrauchsquote",
"allowTransparent": false
},
{
"type": "SelectColor",
"name": "FarbeAutarkie",
"caption": "Autarkiegrad",
"allowTransparent": false
}
]
},
{
"type": "CheckBox",
"name": "Debug",
"caption": "Debug-Logging aktivieren"
}
],
"actions": [
{
"type": "Button",
"caption": "Darstellung aktualisieren",
"onClick": "IPS_RequestAction($id, 'Refresh', true);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Archiv nicht verfuegbar"
}
]
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Energiediagramm;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
final class EnergyChartCalculator
{
public const RANGES = ['day', 'week', 'month', 'year', 'total', 'custom'];
public static function isValidDate(string $date): bool
{
$value = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$errors = DateTimeImmutable::getLastErrors();
return $value !== false
&& ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))
&& $value->format('Y-m-d') === $date;
}
/**
* @return array{0: int, 1: int}
*/
public static function rangeBounds(
string $range,
string $date,
string $customStart,
string $customEnd,
?int $now = null
): array {
if (!in_array($range, self::RANGES, true)) {
throw new InvalidArgumentException('Unbekannter Zeitraum.');
}
$now ??= time();
if ($range === 'total') {
return [0, $now];
}
if ($range === 'custom') {
$start = self::date($customStart);
$end = self::date($customEnd);
if ($start > $end) {
throw new InvalidArgumentException('Das Startdatum liegt nach dem Enddatum.');
}
return [$start->getTimestamp(), $end->modify('+1 day')->getTimestamp()];
}
$base = self::date($date);
switch ($range) {
case 'day':
$start = $base;
$end = $start->modify('+1 day');
break;
case 'week':
$start = $base->modify('monday this week');
$end = $start->modify('+1 week');
break;
case 'month':
$start = $base->modify('first day of this month');
$end = $start->modify('first day of next month');
break;
case 'year':
$start = $base->setDate((int) $base->format('Y'), 1, 1);
$end = $start->modify('+1 year');
break;
default:
throw new InvalidArgumentException('Unbekannter Zeitraum.');
}
return [$start->getTimestamp(), $end->getTimestamp()];
}
/**
* @return array{
* Produktion: float,
* Einspeisung: float,
* Netzbezug: float,
* Hausverbrauch: float,
* Eigenverbrauch: float,
* Eigenverbrauchsquote: float,
* Autarkiegrad: float
* }
*/
public static function metrics(
float $production,
float $feedIn,
float $grid,
?float $consumption = null
): array {
$production = max(0.0, $production);
$feedIn = max(0.0, $feedIn);
$grid = max(0.0, $grid);
$house = $consumption === null
? max(0.0, $production - $feedIn + $grid)
: max(0.0, $consumption);
$selfConsumption = min($production, max(0.0, $house - $grid));
return [
'Produktion' => $production,
'Einspeisung' => $feedIn,
'Netzbezug' => $grid,
'Hausverbrauch' => $house,
'Eigenverbrauch' => $selfConsumption,
'Eigenverbrauchsquote' => $production > 0.0
? min(100.0, ($selfConsumption / $production) * 100.0)
: 0.0,
'Autarkiegrad' => $house > 0.0
? min(100.0, ($selfConsumption / $house) * 100.0)
: 0.0,
];
}
public static function normalizeColor(string $color, string $fallback): string
{
return preg_match('/^#[0-9A-Fa-f]{6}$/', $color) === 1
? strtoupper($color)
: strtoupper($fallback);
}
private static function date(string $date): DateTimeImmutable
{
if (!self::isValidDate($date)) {
throw new InvalidArgumentException('Ungültiges Datum.');
}
return new DateTimeImmutable($date . ' 00:00:00', new DateTimeZone(date_default_timezone_get()));
}
}
+477
View File
@@ -0,0 +1,477 @@
<div id="energy-app">
<div class="toolbar">
<label class="field">
<span>Zeitraum</span>
<select id="range">
<option value="day">Tag</option>
<option value="week">Woche</option>
<option value="month">Monat</option>
<option value="year">Jahr</option>
<option value="total">Gesamt</option>
<option value="custom">Datumsbereich</option>
</select>
</label>
<label id="date-field" class="field">
<span>Datum</span>
<input id="date" type="date">
</label>
<div id="navigation" class="button-row" aria-label="Zeitraum wechseln">
<button id="previous" class="icon-button" type="button" title="Vorheriger Zeitraum" aria-label="Vorheriger Zeitraum">&#8249;</button>
<button id="today" type="button">Heute</button>
<button id="next" class="icon-button" type="button" title="Nächster Zeitraum" aria-label="Nächster Zeitraum">&#8250;</button>
</div>
<div id="custom-range" class="custom-range" hidden>
<label class="field">
<span>Von</span>
<input id="custom-start" type="date">
</label>
<label class="field">
<span>Bis</span>
<input id="custom-end" type="date">
</label>
<button id="apply-range" type="button">Anzeigen</button>
</div>
</div>
<div id="period" class="period" aria-live="polite"></div>
<div id="message" class="message" role="status" hidden></div>
<div id="summary" class="summary"></div>
<div id="charts" class="charts">
<section class="chart" aria-labelledby="self-consumption-title">
<h3 id="self-consumption-title">Eigenverbrauchsquote</h3>
<div id="self-consumption-chart" class="donut"></div>
</section>
<section class="chart" aria-labelledby="autarky-title">
<h3 id="autarky-title">Autarkiegrad</h3>
<div id="autarky-chart" class="donut"></div>
</section>
</div>
<div id="error" class="error" role="alert"></div>
</div>
<style>
:root {
color-scheme: light dark;
}
html,
body {
min-width: 0;
min-height: 100%;
margin: 0;
}
#energy-app {
width: 100%;
min-height: 100%;
box-sizing: border-box;
padding: 14px;
overflow: auto;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: inherit;
background: transparent;
}
.toolbar,
.custom-range,
.button-row,
.summary {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 10px;
}
.field {
display: grid;
min-width: 128px;
gap: 4px;
font-size: 13px;
font-weight: 600;
}
select,
input,
button {
min-height: 36px;
box-sizing: border-box;
border: 1px solid rgba(127, 127, 127, 0.45);
border-radius: 6px;
padding: 7px 10px;
color: inherit;
background: rgba(127, 127, 127, 0.08);
font: inherit;
}
button {
cursor: pointer;
font-weight: 650;
}
button:focus-visible,
select:focus-visible,
input:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.icon-button {
width: 38px;
padding: 0;
font-size: 25px;
line-height: 1;
}
.period {
min-height: 22px;
margin-top: 14px;
font-size: 15px;
font-weight: 700;
}
.message {
margin-top: 10px;
padding: 10px 0;
border-top: 1px solid rgba(127, 127, 127, 0.35);
border-bottom: 1px solid rgba(127, 127, 127, 0.35);
}
.summary {
align-items: stretch;
margin-top: 12px;
}
.metric {
min-width: 130px;
flex: 1 1 150px;
padding: 8px 0;
border-bottom: 2px solid rgba(127, 127, 127, 0.32);
}
.metric span,
.metric strong {
display: block;
}
.metric span {
font-size: 12px;
opacity: 0.72;
}
.metric strong {
margin-top: 3px;
font-size: 16px;
}
.charts {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 22px;
align-items: start;
margin-top: 20px;
}
.charts.narrow {
grid-template-columns: minmax(0, 1fr);
}
.chart {
min-width: 0;
text-align: center;
}
.chart h3 {
margin: 0 0 10px;
font-size: 15px;
letter-spacing: 0;
}
.donut {
width: 176px;
height: 176px;
margin: 0 auto;
}
.donut svg {
display: block;
width: 176px;
height: 176px;
overflow: visible;
}
.donut-track {
stroke: rgba(127, 127, 127, 0.28);
}
.donut-value {
transition: stroke-dasharray 180ms ease;
}
.donut-label {
fill: currentColor;
font-size: 22px;
font-weight: 750;
text-anchor: middle;
dominant-baseline: middle;
}
.error {
min-height: 18px;
margin-top: 8px;
color: #d14343;
font-size: 13px;
}
[hidden] {
display: none !important;
}
@media (max-width: 560px) {
#energy-app {
padding: 10px;
}
.toolbar,
.custom-range {
align-items: stretch;
}
.field {
flex: 1 1 145px;
}
.charts {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<script>
(function () {
'use strict';
const initialState = __ENELIX_INITIAL_STATE__;
const app = document.getElementById('energy-app');
const range = document.getElementById('range');
const date = document.getElementById('date');
const dateField = document.getElementById('date-field');
const navigation = document.getElementById('navigation');
const previous = document.getElementById('previous');
const today = document.getElementById('today');
const next = document.getElementById('next');
const customRange = document.getElementById('custom-range');
const customStart = document.getElementById('custom-start');
const customEnd = document.getElementById('custom-end');
const applyRange = document.getElementById('apply-range');
const period = document.getElementById('period');
const message = document.getElementById('message');
const summary = document.getElementById('summary');
const charts = document.getElementById('charts');
const error = document.getElementById('error');
const selfConsumptionChart = document.getElementById('self-consumption-chart');
const autarkyChart = document.getElementById('autarky-chart');
function send(action, value) {
if (typeof requestAction !== 'function') {
return;
}
try {
requestAction(action, value);
error.textContent = '';
} catch (exception) {
error.textContent = exception && exception.message
? exception.message
: String(exception);
}
}
function asNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function formatEnergy(value) {
return asNumber(value).toLocaleString('de-CH', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}) + ' kWh';
}
function formatDate(value) {
const parts = String(value).split('-');
return parts.length === 3
? parts[2] + '.' + parts[1] + '.' + parts[0]
: '';
}
function periodLabel(data) {
if (data.range === 'total') {
return 'Gesamter Zählerstand';
}
if (data.range === 'custom') {
return formatDate(data.customStart) + ' bis ' + formatDate(data.customEnd);
}
const start = new Date(asNumber(data.tStart) * 1000);
const end = new Date((asNumber(data.tEnd) * 1000) - 1000);
if (data.range === 'day') {
return 'Tag: ' + start.toLocaleDateString('de-CH');
}
if (data.range === 'week') {
return 'Woche: ' + start.toLocaleDateString('de-CH')
+ ' bis ' + end.toLocaleDateString('de-CH');
}
if (data.range === 'month') {
return 'Monat: ' + start.toLocaleDateString('de-CH', {
month: 'long',
year: 'numeric'
});
}
if (data.range === 'year') {
return 'Jahr: ' + start.getFullYear();
}
return '';
}
function donut(target, percent, color) {
const value = Math.max(0, Math.min(100, asNumber(percent)));
const radius = 58;
const circumference = 2 * Math.PI * radius;
const filled = (value / 100) * circumference;
target.innerHTML = [
'<svg viewBox="0 0 176 176" role="img" aria-label="'
+ value.toFixed(1) + ' Prozent">',
'<circle class="donut-track" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke-width="18"></circle>',
'<circle class="donut-value" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke="' + color + '" stroke-width="18"'
+ ' stroke-linecap="round" transform="rotate(-90 88 88)"'
+ ' stroke-dasharray="' + filled + ' ' + circumference + '"></circle>',
'<text class="donut-label" x="88" y="91">'
+ value.toFixed(1) + '%</text>',
'</svg>'
].join('');
}
function metric(label, value) {
return '<div class="metric"><span>' + label + '</span><strong>'
+ formatEnergy(value) + '</strong></div>';
}
function render(data) {
if (!data || typeof data !== 'object') {
return;
}
range.value = data.range || 'day';
date.value = data.date || '';
customStart.value = data.customStart || '';
customEnd.value = data.customEnd || '';
const currentMode = range.value;
const custom = currentMode === 'custom';
const total = currentMode === 'total';
dateField.hidden = custom || total;
navigation.hidden = custom || total;
customRange.hidden = !custom;
period.textContent = periodLabel(data);
const values = data.values || {};
summary.innerHTML = [
metric('Produktion', values.Produktion),
metric('Hausverbrauch', values.Hausverbrauch),
metric('Netzbezug', values.Netzbezug),
metric('Einspeisung', values.Einspeisung)
].join('');
const colors = data.colors || {};
donut(
selfConsumptionChart,
values.Eigenverbrauchsquote,
colors.selfConsumption || '#2F80ED'
);
donut(
autarkyChart,
values.Autarkiegrad,
colors.autarky || '#22A06B'
);
message.hidden = data.hasData !== false;
message.textContent = data.hasData === false
? (data.noDataHint || 'Keine Archivwerte verfügbar.')
: '';
}
window.handleMessage = function (data) {
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (exception) {
error.textContent = 'Die Aktualisierung enthält ungültige Daten.';
return;
}
}
render(data);
};
range.addEventListener('change', function () {
send('SetRange', range.value);
});
date.addEventListener('change', function () {
send('SetDate', date.value);
});
previous.addEventListener('click', function () {
send('Prev', true);
});
next.addEventListener('click', function () {
send('Next', true);
});
today.addEventListener('click', function () {
send('Today', true);
});
applyRange.addEventListener('click', function () {
send('SetCustomRange', JSON.stringify({
start: customStart.value,
end: customEnd.value
}));
});
const now = new Date();
const currentDate = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0')
].join('-');
date.max = currentDate;
customStart.max = currentDate;
customEnd.max = currentDate;
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(function (entries) {
const width = entries[0] ? entries[0].contentRect.width : app.clientWidth;
charts.classList.toggle('narrow', width < 520);
});
observer.observe(app);
}
document.addEventListener('visibilitychange', function () {
if (!document.hidden) {
send('Refresh', true);
}
});
render(initialState);
window.setTimeout(function () {
send('Refresh', true);
}, 0);
})();
</script>
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{F73B8F8D-5E36-4B23-B379-4632A517A1D6}",
"name": "Energiediagramm",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Energy Pie"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIXENERGY",
"url": ""
}
+482
View File
@@ -0,0 +1,482 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/libs/EnergyChartCalculator.php';
use Belevo\EnelixUtils\Energiediagramm\EnergyChartCalculator;
class Energiediagramm extends IPSModule
{
private const ARCHIVE_MODULE_ID = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
private const ATTR_RANGE = 'SelectedRange';
private const ATTR_DATE = 'SelectedDate';
private const ATTR_CUSTOM_START = 'CustomStart';
private const ATTR_CUSTOM_END = 'CustomEnd';
private const COLOR_SELF_CONSUMPTION = '#2F80ED';
private const COLOR_AUTARKY = '#22A06B';
public function Create(): void
{
parent::Create();
$this->RegisterPropertyInteger('ProduktionZaehlerID', 0);
$this->RegisterPropertyInteger('EinspeisungZaehlerID', 0);
$this->RegisterPropertyInteger('NetzbezugZaehlerID', 0);
$this->RegisterPropertyInteger('VerbrauchZaehlerID', 0);
$this->RegisterPropertyFloat('Zaehlerfaktor', 1.0);
$this->RegisterPropertyString('Standardzeitraum', 'day');
$this->RegisterPropertyInteger('Aktualisierungsintervall', 60);
$this->RegisterPropertyString('FarbeEigenverbrauch', self::COLOR_SELF_CONSUMPTION);
$this->RegisterPropertyString('FarbeAutarkie', self::COLOR_AUTARKY);
$this->RegisterPropertyBoolean('Debug', false);
$today = date('Y-m-d');
$this->RegisterAttributeString(self::ATTR_RANGE, 'day');
$this->RegisterAttributeString(self::ATTR_DATE, $today);
$this->RegisterAttributeString(self::ATTR_CUSTOM_START, $today);
$this->RegisterAttributeString(self::ATTR_CUSTOM_END, $today);
$this->SetVisualizationType(1);
$this->RegisterTimer(
'Refresh',
0,
'IPS_RequestAction($_IPS["TARGET"], "Refresh", true);'
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$today = date('Y-m-d');
$defaultRange = $this->ReadPropertyString('Standardzeitraum');
if (!in_array($defaultRange, ['day', 'week', 'month', 'year', 'total'], true)) {
$defaultRange = 'day';
}
$this->WriteAttributeString(self::ATTR_RANGE, $defaultRange);
$this->WriteAttributeString(self::ATTR_DATE, $today);
if (!$this->validStoredDate($this->ReadAttributeString(self::ATTR_CUSTOM_START))) {
$this->WriteAttributeString(self::ATTR_CUSTOM_START, $today);
}
if (!$this->validStoredDate($this->ReadAttributeString(self::ATTR_CUSTOM_END))) {
$this->WriteAttributeString(self::ATTR_CUSTOM_END, $today);
}
$interval = $this->ReadPropertyInteger('Aktualisierungsintervall');
$this->SetTimerInterval('Refresh', max(10, min(3600, $interval)) * 1000);
if ($this->requiredSourceIdsAreEmpty()) {
$this->SetStatus(104);
} elseif (!$this->configurationIsValid()) {
$this->SetStatus(201);
} elseif ($this->archiveId() === 0) {
$this->SetStatus(202);
} else {
$this->SetStatus(102);
}
$this->recalculateAndPush();
}
public function GetVisualizationTile(): string
{
$path = __DIR__ . '/module.html';
$html = @file_get_contents($path);
if ($html === false) {
return '<div style="padding:12px">Darstellung nicht verfügbar.</div>';
}
try {
$initial = json_encode(
$this->buildPayload(),
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR
);
} catch (Throwable $error) {
$initial = '{"hasData":false,"noDataHint":"Darstellung konnte nicht geladen werden."}';
}
return str_replace('__ENELIX_INITIAL_STATE__', $initial, $html);
}
public function GetVisualizationPopup(): string
{
return $this->GetVisualizationTile();
}
public function RequestAction($Ident, $Value): void
{
try {
switch ((string) $Ident) {
case 'SetRange':
$this->setRange((string) $Value);
break;
case 'SetDate':
$this->setDate((string) $Value);
break;
case 'SetCustomRange':
$this->setCustomRange((string) $Value);
break;
case 'Prev':
case 'Next':
case 'Today':
$this->shiftDate((string) $Ident);
break;
case 'Refresh':
break;
default:
throw new InvalidArgumentException('Unbekannte Aktion.');
}
$this->recalculateAndPush();
} catch (Throwable $error) {
$this->debug('Aktion abgewiesen', $error->getMessage());
}
}
private function setRange(string $range): void
{
if (!in_array($range, EnergyChartCalculator::RANGES, true)) {
throw new InvalidArgumentException('Unbekannter Zeitraum.');
}
$this->WriteAttributeString(self::ATTR_RANGE, $range);
}
private function setDate(string $date): void
{
if (!$this->validStoredDate($date)) {
throw new InvalidArgumentException('Ungültiges oder zukünftiges Datum.');
}
$this->WriteAttributeString(self::ATTR_DATE, $date);
}
private function setCustomRange(string $json): void
{
try {
$value = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $error) {
throw new InvalidArgumentException('Ungültiger Datumsbereich.', 0, $error);
}
$start = is_array($value) ? (string) ($value['start'] ?? '') : '';
$end = is_array($value) ? (string) ($value['end'] ?? '') : '';
if (!$this->validStoredDate($start) || !$this->validStoredDate($end)) {
throw new InvalidArgumentException('Ungültiger oder zukünftiger Datumsbereich.');
}
if ($start > $end) {
throw new InvalidArgumentException('Das Startdatum liegt nach dem Enddatum.');
}
$this->WriteAttributeString(self::ATTR_CUSTOM_START, $start);
$this->WriteAttributeString(self::ATTR_CUSTOM_END, $end);
$this->WriteAttributeString(self::ATTR_RANGE, 'custom');
}
private function shiftDate(string $action): void
{
$range = $this->ReadAttributeString(self::ATTR_RANGE);
if ($range === 'total' || $range === 'custom') {
return;
}
if ($action === 'Today') {
$this->WriteAttributeString(self::ATTR_DATE, date('Y-m-d'));
return;
}
$date = $this->ReadAttributeString(self::ATTR_DATE);
$base = strtotime($date . ' 00:00:00');
if ($base === false) {
$base = strtotime(date('Y-m-d') . ' 00:00:00');
}
$previous = $action === 'Prev';
$modifier = [
'day' => $previous ? '-1 day' : '+1 day',
'week' => $previous ? '-1 week' : '+1 week',
'month' => $previous ? 'first day of previous month' : 'first day of next month',
'year' => $previous ? 'first day of january previous year' : 'first day of january next year',
][$range] ?? 'today';
$shifted = strtotime($modifier, $base);
if ($shifted === false || $shifted > time()) {
$shifted = time();
}
$this->WriteAttributeString(self::ATTR_DATE, date('Y-m-d', $shifted));
}
private function recalculateAndPush(): void
{
try {
$payload = $this->buildPayload();
$this->UpdateVisualizationValue(json_encode($payload, JSON_THROW_ON_ERROR));
} catch (Throwable $error) {
$this->debug('Aktualisierung fehlgeschlagen', $error->getMessage());
}
}
/**
* @return array<string, mixed>
*/
private function buildPayload(): array
{
$range = $this->ReadAttributeString(self::ATTR_RANGE);
if (!in_array($range, EnergyChartCalculator::RANGES, true)) {
$range = $this->ReadPropertyString('Standardzeitraum');
}
if (!in_array($range, EnergyChartCalculator::RANGES, true)) {
$range = 'day';
}
$date = $this->ReadAttributeString(self::ATTR_DATE);
if (!$this->validStoredDate($date)) {
$date = date('Y-m-d');
}
$customStart = $this->ReadAttributeString(self::ATTR_CUSTOM_START);
$customEnd = $this->ReadAttributeString(self::ATTR_CUSTOM_END);
if (!$this->validStoredDate($customStart)) {
$customStart = date('Y-m-d');
}
if (!$this->validStoredDate($customEnd)) {
$customEnd = date('Y-m-d');
}
[$start, $end] = EnergyChartCalculator::rangeBounds(
$range,
$date,
$customStart,
$customEnd
);
$payload = [
'range' => $range,
'date' => $date,
'customStart' => $customStart,
'customEnd' => $customEnd,
'tStart' => $start,
'tEnd' => $end,
'hasData' => false,
'noDataHint' => 'Energiezähler konfigurieren.',
'colors' => [
'selfConsumption' => EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeEigenverbrauch'),
self::COLOR_SELF_CONSUMPTION
),
'autarky' => EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeAutarkie'),
self::COLOR_AUTARKY
),
],
'values' => EnergyChartCalculator::metrics(0.0, 0.0, 0.0),
];
if (!$this->configurationIsValid()) {
return $payload;
}
$archiveId = $this->archiveId();
if ($archiveId === 0) {
$payload['noDataHint'] = 'Das IP-Symcon-Archiv ist nicht verfügbar.';
return $payload;
}
$factor = $this->ReadPropertyFloat('Zaehlerfaktor');
$total = $range === 'total';
$production = $this->readEnergy(
$archiveId,
$this->ReadPropertyInteger('ProduktionZaehlerID'),
$start,
$end,
$total
);
$feedIn = $this->readEnergy(
$archiveId,
$this->ReadPropertyInteger('EinspeisungZaehlerID'),
$start,
$end,
$total
);
$grid = $this->readEnergy(
$archiveId,
$this->ReadPropertyInteger('NetzbezugZaehlerID'),
$start,
$end,
$total
);
$consumption = null;
$consumptionAvailable = true;
$consumptionId = $this->ReadPropertyInteger('VerbrauchZaehlerID');
if ($consumptionId > 0) {
$consumptionResult = $this->readEnergy($archiveId, $consumptionId, $start, $end, $total);
$consumptionAvailable = $consumptionResult['available'];
if ($consumptionAvailable) {
$consumption = $consumptionResult['value'] * $factor;
}
}
$payload['hasData'] = $production['available']
&& $feedIn['available']
&& $grid['available']
&& $consumptionAvailable;
$payload['values'] = EnergyChartCalculator::metrics(
$production['value'] * $factor,
$feedIn['value'] * $factor,
$grid['value'] * $factor,
$consumption
);
if (!$payload['hasData']) {
$lastLog = max(
$this->lastLogTimestamp($archiveId, $this->ReadPropertyInteger('ProduktionZaehlerID')),
$this->lastLogTimestamp($archiveId, $this->ReadPropertyInteger('EinspeisungZaehlerID')),
$this->lastLogTimestamp($archiveId, $this->ReadPropertyInteger('NetzbezugZaehlerID')),
$this->lastLogTimestamp($archiveId, $consumptionId)
);
$payload['noDataHint'] = $lastLog > 0
? 'Letzter Archivwert: ' . date('d.m.Y H:i', $lastLog)
: 'Für diesen Zeitraum sind keine Archivwerte vorhanden.';
} else {
$payload['noDataHint'] = '';
}
return $payload;
}
/**
* @return array{value: float, available: bool}
*/
private function readEnergy(
int $archiveId,
int $variableId,
int $start,
int $end,
bool $total
): array {
if (!$this->isNumericVariable($variableId)) {
return ['value' => 0.0, 'available' => false];
}
if ($total) {
$value = GetValue($variableId);
return [
'value' => is_numeric($value) ? max(0.0, (float) $value) : 0.0,
'available' => is_numeric($value),
];
}
$before = @AC_GetLoggedValues($archiveId, $variableId, 0, $start, 1);
$within = @AC_GetLoggedValues($archiveId, $variableId, $start, min($end, time()), 0);
$before = is_array($before) ? $before : [];
$within = is_array($within) ? $within : [];
usort($before, static fn (array $left, array $right): int =>
((int) $left['TimeStamp']) <=> ((int) $right['TimeStamp'])
);
usort($within, static fn (array $left, array $right): int =>
((int) $left['TimeStamp']) <=> ((int) $right['TimeStamp'])
);
if ($before === [] && $within === []) {
return ['value' => 0.0, 'available' => false];
}
$startValue = $before !== []
? (float) $before[count($before) - 1]['Value']
: (float) $within[0]['Value'];
$endValue = $within !== []
? (float) $within[count($within) - 1]['Value']
: $startValue;
return [
'value' => max(0.0, $endValue - $startValue),
'available' => true,
];
}
private function lastLogTimestamp(int $archiveId, int $variableId): int
{
if (!$this->isNumericVariable($variableId)) {
return 0;
}
$values = @AC_GetLoggedValues($archiveId, $variableId, 0, time(), 1);
return is_array($values) && isset($values[0]['TimeStamp'])
? (int) $values[0]['TimeStamp']
: 0;
}
private function configurationIsValid(): bool
{
$requiredIds = [
$this->ReadPropertyInteger('ProduktionZaehlerID'),
$this->ReadPropertyInteger('EinspeisungZaehlerID'),
$this->ReadPropertyInteger('NetzbezugZaehlerID'),
];
foreach ($requiredIds as $variableId) {
if (!$this->isNumericVariable($variableId)) {
return false;
}
}
$consumptionId = $this->ReadPropertyInteger('VerbrauchZaehlerID');
if ($consumptionId > 0 && !$this->isNumericVariable($consumptionId)) {
return false;
}
return $this->ReadPropertyFloat('Zaehlerfaktor') > 0.0
&& $this->ReadPropertyInteger('Aktualisierungsintervall') >= 10
&& $this->ReadPropertyInteger('Aktualisierungsintervall') <= 3600
&& in_array(
$this->ReadPropertyString('Standardzeitraum'),
['day', 'week', 'month', 'year', 'total'],
true
)
&& EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeEigenverbrauch'),
''
) !== ''
&& EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeAutarkie'),
''
) !== '';
}
private function requiredSourceIdsAreEmpty(): bool
{
return $this->ReadPropertyInteger('ProduktionZaehlerID') === 0
&& $this->ReadPropertyInteger('EinspeisungZaehlerID') === 0
&& $this->ReadPropertyInteger('NetzbezugZaehlerID') === 0;
}
private function archiveId(): int
{
$archives = IPS_GetInstanceListByModuleID(self::ARCHIVE_MODULE_ID);
return isset($archives[0]) ? (int) $archives[0] : 0;
}
private function isNumericVariable(int $variableId): bool
{
if ($variableId <= 0 || !IPS_VariableExists($variableId)) {
return false;
}
$variable = IPS_GetVariable($variableId);
return in_array((int) ($variable['VariableType'] ?? -1), [1, 2], true);
}
private function validStoredDate(string $date): bool
{
return EnergyChartCalculator::isValidDate($date)
&& $date <= date('Y-m-d');
}
private function debug(string $title, string $message): void
{
if ($this->ReadPropertyBoolean('Debug')) {
$this->SendDebug($title, $message, 0);
}
}
}