You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
448 lines
13 KiB
Python
448 lines
13 KiB
Python
import html
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
import httpx
|
|
|
|
|
|
METER_NUMBER = PARAMS["meter_number"]
|
|
CHART_TYPE = PARAMS.get("chart_type", "line")
|
|
GROUP_BY = PARAMS.get("group_by", "hour")
|
|
INCLUDE_STATISTICS = PARAMS.get("include_statistics", True)
|
|
|
|
FIND_SENSORS_QUERY = """
|
|
query FindSensors($meterNumber: String!) {
|
|
sensorsForMeterNumber(meterNumber: $meterNumber) {
|
|
sensorId
|
|
sensorName
|
|
sensorNameExtern
|
|
descr
|
|
measureConcept {
|
|
id
|
|
name
|
|
descr
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
GET_VARIABLES_QUERY = """
|
|
query GetVariables($sensorId: ID!) {
|
|
availableVariableUnits(sensorId: $sensorId) {
|
|
variableUnitId
|
|
variableName
|
|
unitName
|
|
}
|
|
}
|
|
"""
|
|
|
|
FIND_OBSERVATIONS_QUERY = """
|
|
query FindObservations($measurementConceptId: ID!, $sensorName: String, $startTime: String, $endTime: String) {
|
|
findObservation(
|
|
measurementConceptId: $measurementConceptId
|
|
sensorName: $sensorName
|
|
startTime: $startTime
|
|
endTime: $endTime
|
|
) {
|
|
id
|
|
moment
|
|
value
|
|
meterValue
|
|
observationVariableUnit {
|
|
observationVariable {
|
|
name
|
|
description
|
|
}
|
|
unit {
|
|
name
|
|
description
|
|
}
|
|
}
|
|
quality
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
def escape_text(value, default=""):
|
|
text = default if value is None else str(value)
|
|
return html.escape(text, quote=True)
|
|
|
|
|
|
def build_message(message, css_class):
|
|
return (
|
|
f"<div class='{css_class}' "
|
|
"style='font-family: Segoe UI, Tahoma, Geneva, Verdana, sans-serif; "
|
|
"padding: 16px; border-radius: 10px; margin: 20px;'>"
|
|
f"{escape_text(message)}"
|
|
"</div>"
|
|
)
|
|
|
|
|
|
def make_graphql_request(query, variables):
|
|
try:
|
|
with httpx.Client() as client:
|
|
response = client.post(
|
|
f"{EXTERNAL_BASE_URL}/graphql",
|
|
headers=AUTH_HEADERS,
|
|
json={"query": query, "variables": variables},
|
|
timeout=30.0,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except Exception as exc:
|
|
return {"errors": [str(exc)]}
|
|
|
|
|
|
def calculate_statistics(observations):
|
|
if not observations:
|
|
return {}
|
|
|
|
values = [obs.get("value") or 0 for obs in observations]
|
|
meter_values = [obs.get("meterValue") or 0 for obs in observations]
|
|
|
|
return {
|
|
"total_readings": len(observations),
|
|
"value_min": min(values) if values else 0,
|
|
"value_max": max(values) if values else 0,
|
|
"value_avg": sum(values) / len(values) if values else 0,
|
|
"meter_min": min(meter_values) if meter_values else 0,
|
|
"meter_max": max(meter_values) if meter_values else 0,
|
|
"meter_avg": sum(meter_values) / len(meter_values) if meter_values else 0,
|
|
"consumption": max(meter_values) - min(meter_values) if meter_values else 0,
|
|
}
|
|
|
|
|
|
def format_datetime(dt_string):
|
|
try:
|
|
dt = datetime.fromisoformat(dt_string.replace("Z", "+00:00"))
|
|
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
return dt_string
|
|
|
|
|
|
def build_report():
|
|
sensors_response = make_graphql_request(
|
|
FIND_SENSORS_QUERY,
|
|
{"meterNumber": METER_NUMBER},
|
|
)
|
|
|
|
if "errors" in sensors_response:
|
|
return build_message(
|
|
f"Fehler beim Abrufen der Sensoren: {sensors_response['errors']}",
|
|
"error",
|
|
)
|
|
|
|
sensors = sensors_response.get("data", {}).get("sensorsForMeterNumber", [])
|
|
if not sensors:
|
|
return build_message(
|
|
f"Keine Sensoren fuer Zaehlernummer '{METER_NUMBER}' gefunden.",
|
|
"warning",
|
|
)
|
|
|
|
sensor = sensors[0]
|
|
sensor_id = sensor["sensorId"]
|
|
sensor_name = sensor["sensorName"]
|
|
measure_concept = sensor.get("measureConcept") or {}
|
|
measure_concept_id = measure_concept.get("id")
|
|
measure_concept_name = measure_concept.get("name") or "Unbekannt"
|
|
|
|
variables_response = make_graphql_request(
|
|
GET_VARIABLES_QUERY,
|
|
{"sensorId": sensor_id},
|
|
)
|
|
available_vars = variables_response.get("data", {}).get("availableVariableUnits", [])
|
|
|
|
end_time = datetime.now()
|
|
start_time = end_time - timedelta(days=90)
|
|
|
|
observations_response = make_graphql_request(
|
|
FIND_OBSERVATIONS_QUERY,
|
|
{
|
|
"measurementConceptId": measure_concept_id,
|
|
"sensorName": sensor_name,
|
|
"startTime": start_time.isoformat(),
|
|
"endTime": end_time.isoformat(),
|
|
},
|
|
)
|
|
|
|
if "errors" in observations_response:
|
|
return build_message(
|
|
f"Fehler beim Abrufen der Messwerte: {observations_response['errors']}",
|
|
"error",
|
|
)
|
|
|
|
observations = observations_response.get("data", {}).get("findObservation", [])
|
|
stats = calculate_statistics(observations)
|
|
|
|
chart_labels = []
|
|
chart_values = []
|
|
chart_meter_values = []
|
|
|
|
for obs in observations:
|
|
chart_labels.append(format_datetime(obs.get("moment", "")))
|
|
chart_values.append(obs.get("value") or 0)
|
|
chart_meter_values.append(obs.get("meterValue") or 0)
|
|
|
|
safe_sensor_name = escape_text(sensor_name, "Unbekannt")
|
|
safe_meter_number = escape_text(METER_NUMBER)
|
|
safe_measure_concept_name = escape_text(measure_concept_name, "Unbekannt")
|
|
|
|
result_html = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Messwerte Dashboard - {safe_sensor_name}</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<style>
|
|
* {{
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}}
|
|
body {{
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}}
|
|
.dashboard {{
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border-radius: 20px;
|
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
}}
|
|
.header {{
|
|
background: linear-gradient(90deg, var(--color-primary, #4f46e5) 0%, #7c3aed 100%);
|
|
color: white;
|
|
padding: 30px;
|
|
text-align: center;
|
|
}}
|
|
.header h1 {{
|
|
font-size: 2.5rem;
|
|
margin-bottom: 10px;
|
|
}}
|
|
.header .subtitle {{
|
|
opacity: 0.9;
|
|
font-size: 1.1rem;
|
|
}}
|
|
.content {{
|
|
padding: 30px;
|
|
}}
|
|
.stats-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}}
|
|
.stat-card {{
|
|
background: white;
|
|
padding: 25px;
|
|
border-radius: 15px;
|
|
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
|
|
border-left: 5px solid var(--color-primary, #4f46e5);
|
|
transition: transform 0.2s;
|
|
}}
|
|
.stat-card:hover {{
|
|
transform: translateY(-5px);
|
|
}}
|
|
.stat-value {{
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
color: var(--color-primary, #4f46e5);
|
|
margin-bottom: 5px;
|
|
}}
|
|
.stat-label {{
|
|
color: #6b7280;
|
|
font-weight: 500;
|
|
}}
|
|
.chart-container {{
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 15px;
|
|
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
|
|
margin-bottom: 20px;
|
|
}}
|
|
.chart-title {{
|
|
font-size: 1.5rem;
|
|
margin-bottom: 20px;
|
|
color: #374151;
|
|
text-align: center;
|
|
}}
|
|
.info-box {{
|
|
background: #f3f4f6;
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
margin-top: 20px;
|
|
}}
|
|
.warning {{
|
|
background: #fef3cd;
|
|
color: #856404;
|
|
}}
|
|
.error {{
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="dashboard">
|
|
<div class="header">
|
|
<h1>Messwerte Dashboard</h1>
|
|
<div class="subtitle">Sensor: {safe_sensor_name} | Zaehlernummer: {safe_meter_number}</div>
|
|
<div class="subtitle">Zeitraum: {start_time.strftime('%d.%m.%Y')} - {end_time.strftime('%d.%m.%Y')}</div>
|
|
<div class="subtitle">Gruppierung: {escape_text(GROUP_BY)}</div>
|
|
</div>
|
|
|
|
<div class="content">
|
|
"""
|
|
|
|
if INCLUDE_STATISTICS and stats:
|
|
result_html += f"""
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-value">{stats['total_readings']:,}</div>
|
|
<div class="stat-label">Gesamte Messwerte</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{stats['consumption']:,.2f}</div>
|
|
<div class="stat-label">Verbrauch (Differenz)</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{stats['value_avg']:,.2f}</div>
|
|
<div class="stat-label">Durchschnitt Messwert</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{stats['meter_max']:,.2f}</div>
|
|
<div class="stat-label">Max Zaehlerstand</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
result_html += f"""
|
|
<div class="chart-container">
|
|
<h2 class="chart-title">Messwerte Verlauf ({escape_text(CHART_TYPE.title())})</h2>
|
|
<canvas id="mainChart" width="400" height="200"></canvas>
|
|
</div>
|
|
|
|
<div class="chart-container">
|
|
<h2 class="chart-title">Zaehlerstaende</h2>
|
|
<canvas id="meterChart" width="400" height="200"></canvas>
|
|
</div>
|
|
|
|
<div class="info-box">
|
|
<h3>Verfuegbare Variablen:</h3>
|
|
<ul>
|
|
"""
|
|
|
|
for var in available_vars:
|
|
variable_name = escape_text(var.get("variableName"), "Unbekannt")
|
|
unit_name = escape_text(var.get("unitName"), "-")
|
|
result_html += f" <li>{variable_name} ({unit_name})</li>\n"
|
|
|
|
result_html += f"""
|
|
</ul>
|
|
<p><strong>Anzahl Sensoren gefunden:</strong> {len(sensors)}</p>
|
|
<p><strong>Measure Concept:</strong> {safe_measure_concept_name}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const chartLabels = {json.dumps(chart_labels[-100:])};
|
|
const chartValues = {json.dumps(chart_values[-100:])};
|
|
const chartMeterValues = {json.dumps(chart_meter_values[-100:])};
|
|
|
|
const ctx1 = document.getElementById('mainChart').getContext('2d');
|
|
new Chart(ctx1, {{
|
|
type: {json.dumps(CHART_TYPE)},
|
|
data: {{
|
|
labels: chartLabels,
|
|
datasets: [{{
|
|
label: 'Messwerte',
|
|
data: chartValues,
|
|
borderColor: 'rgb(79, 70, 229)',
|
|
backgroundColor: 'rgba(79, 70, 229, 0.1)',
|
|
borderWidth: 3,
|
|
fill: true,
|
|
tension: 0.4
|
|
}}]
|
|
}},
|
|
options: {{
|
|
responsive: true,
|
|
plugins: {{
|
|
legend: {{
|
|
display: true,
|
|
position: 'top'
|
|
}}
|
|
}},
|
|
scales: {{
|
|
x: {{
|
|
display: true,
|
|
ticks: {{
|
|
maxTicksLimit: 10
|
|
}}
|
|
}},
|
|
y: {{
|
|
display: true,
|
|
beginAtZero: false
|
|
}}
|
|
}}
|
|
}}
|
|
}});
|
|
|
|
const ctx2 = document.getElementById('meterChart').getContext('2d');
|
|
new Chart(ctx2, {{
|
|
type: 'line',
|
|
data: {{
|
|
labels: chartLabels,
|
|
datasets: [{{
|
|
label: 'Zaehlerstand',
|
|
data: chartMeterValues,
|
|
borderColor: 'rgb(34, 197, 94)',
|
|
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
|
borderWidth: 3,
|
|
fill: true,
|
|
tension: 0.4
|
|
}}]
|
|
}},
|
|
options: {{
|
|
responsive: true,
|
|
plugins: {{
|
|
legend: {{
|
|
display: true,
|
|
position: 'top'
|
|
}}
|
|
}},
|
|
scales: {{
|
|
x: {{
|
|
display: true,
|
|
ticks: {{
|
|
maxTicksLimit: 10
|
|
}}
|
|
}},
|
|
y: {{
|
|
display: true,
|
|
beginAtZero: false
|
|
}}
|
|
}}
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return result_html
|
|
|
|
|
|
try:
|
|
html_output = build_report()
|
|
result = {"type": "html", "content": html_output}
|
|
except Exception as exc:
|
|
result = {"type": "html", "content": build_message(f"Allgemeiner Fehler: {exc}", "error")}
|