This commit is contained in:
Mateusz Gruszczyński
2026-02-03 21:43:42 +01:00
commit e3724e1249
12 changed files with 474 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.gitignore
__pycache__
*.pyc
venv
.env
README.md
.vscode
.idea

7
.gitignore Normal file
View File

@@ -0,0 +1,7 @@
__pycache__
venv/
env
.env
.vscode/
.idea/
.DS_Store

9
Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM python:3.14-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8798
CMD ["python", "app.py"]

0
README.md Normal file
View File

86
app.py Normal file
View File

@@ -0,0 +1,86 @@
import eventlet
eventlet.monkey_patch()
from flask import Flask, render_template, jsonify, request
from flask_socketio import SocketIO
from influxdb import InfluxDBClient
import threading
import time
from datetime import datetime
import config
import os
app = Flask(__name__)
app.config['SECRET_KEY'] = config.FLASK_CONFIG['secret_key']
socketio = SocketIO(app,
cors_allowed_origins="*",
async_mode='eventlet',
ping_timeout=60,
ping_interval=25)
def get_influx_client():
client = InfluxDBClient(
host=config.INFLUXDB_CONFIG['host'],
port=config.INFLUXDB_CONFIG['port'],
database=config.INFLUXDB_CONFIG['database']
)
if config.INFLUXDB_CONFIG['username']:
client.switch_user(config.INFLUXDB_CONFIG['username'], config.INFLUXDB_CONFIG['password'])
return client
def get_current_voltage(phase_id):
client = get_influx_client()
entity_id = config.PHASES[phase_id]['entity_id']
query = f'SELECT "value" FROM "{config.MEASUREMENT}" WHERE "entity_id" = \'{entity_id}\' ORDER BY time DESC LIMIT 1'
try:
result = client.query(query)
points = list(result.get_points())
if points:
return {'voltage': round(float(points[0]['value']), 2), 'timestamp': points[0]['time']}
except Exception as e:
print(f"Influx Error Phase {phase_id}: {e}")
finally:
client.close()
return {'voltage': 0, 'timestamp': None}
@app.route('/')
def index():
return render_template('index.html', phases=config.PHASES, time_ranges=config.TIME_RANGES,
default_range=config.DEFAULT_TIME_RANGE, footer=config.FOOTER)
@app.route('/api/timeseries/<int:phase_id>')
def api_timeseries(phase_id):
if phase_id not in config.PHASES: return jsonify({'error': 'Invalid phase'}), 400
client = get_influx_client()
t_range = request.args.get('range', config.DEFAULT_TIME_RANGE)
cfg = config.TIME_RANGES.get(t_range, config.TIME_RANGES['24h'])
query = config.PHASES[phase_id]['query'].replace('$timeFilter', cfg['filter']).replace('$__interval', cfg['interval'])
try:
result = client.query(query)
data = [{'time': p['time'], 'voltage': round(p['mean'], 2)} for p in result.get_points() if p.get('mean') is not None]
return jsonify(data)
except Exception as e:
print(f"History Error: {e}")
return jsonify([])
finally:
client.close()
def background_voltage_update():
print("Background worker started...")
while True:
try:
voltages = {'timestamp': None}
for pid in config.PHASES.keys():
data = get_current_voltage(pid)
voltages[f'phase{pid}'] = data['voltage']
if data['timestamp']: voltages['timestamp'] = data['timestamp']
socketio.emit('voltage_update', voltages)
except Exception as e:
print(f"Worker Loop Error: {e}")
eventlet.sleep(config.CHART_CONFIG['update_interval'] / 1000)
if __name__ == '__main__':
eventlet.spawn(background_voltage_update)
socketio.run(app, host='0.0.0.0', port=config.FLASK_CONFIG['port'])

66
config.py Normal file
View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
import os
# Konfiguracja InfluxDB - Twoje specyficzne ustawienia
INFLUXDB_CONFIG = {
'host': os.getenv('INFLUXDB_HOST', 'stats.mngmnt.r.local'),
'port': int(os.getenv('INFLUXDB_PORT', 8086)),
'database': os.getenv('INFLUXDB_DATABASE', 'ha'),
'username': os.getenv('INFLUXDB_USER', ''),
'password': os.getenv('INFLUXDB_PASSWORD', ''),
}
# Konfiguracja Faz i zapytań SQL
PHASES = {
1: {
'entity_id': '0_electricity_meter_voltage_phase_1',
'label': 'L1',
'color': '#0d6efd',
'query': 'SELECT mean("value") FROM "V" WHERE ("entity_id" = \'0_electricity_meter_voltage_phase_1\') AND time > now() - $timeFilter GROUP BY time($__interval) fill(null)'
},
2: {
'entity_id': '0_electricity_meter_voltage_phase_2',
'label': 'L2',
'color': '#198754',
'query': 'SELECT mean("value") FROM "V" WHERE ("entity_id" = \'0_electricity_meter_voltage_phase_2\') AND time > now() - $timeFilter GROUP BY time($__interval) fill(null)'
},
3: {
'entity_id': '0_electricity_meter_voltage_phase_3',
'label': 'L3',
'color': '#dc3545',
'query': 'SELECT mean("value") FROM "V" WHERE ("entity_id" = \'0_electricity_meter_voltage_phase_3\') AND time > now() - $timeFilter GROUP BY time($__interval) fill(null)'
}
}
# Zakresy czasu
TIME_RANGES = {
'1h': {'filter': '1h', 'interval': '1m', 'label': '1h'},
'6h': {'filter': '6h', 'interval': '5m', 'label': '6h'},
'12h': {'filter': '12h', 'interval': '10m', 'label': '12h'},
'24h': {'filter': '24h', 'interval': '1h', 'label': '24h'},
'7d': {'filter': '7d', 'interval': '6h', 'label': '7d'},
'30d': {'filter': '30d', 'interval': '1d', 'label': '30d'}
}
DEFAULT_TIME_RANGE = '6h'
MEASUREMENT = 'V'
# ZAKRES BEZPIECZNY: 207V - 253V
VOLTAGE_THRESHOLDS = {
'min_safe': 207,
'max_safe': 253
}
# Skalowanie
GAUGE_CONFIG = {'min': 190, 'max': 270}
CHART_CONFIG = {'y_min': 190, 'y_max': 270, 'update_interval': 2000}
# Flask settings - PORT 8798
FLASK_CONFIG = {
'host': '0.0.0.0',
'port': 8798,
'debug': False,
'secret_key': os.getenv('SECRET_KEY', 'voltage-monitor-secret-key')
}
FOOTER = {'author': 'www.linuxiarz.pl', 'year': '2026'}

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
voltage-monitor:
build: .
container_name: voltage-monitor
ports:
- "8798:8798"
volumes:
- ./config.py:/app/config.py:ro
- ./static:/app/static:ro
environment:
- INFLUXDB_HOST=stats.mngmnt.r.local
- INFLUXDB_PORT=8086
- INFLUXDB_DATABASE=ha
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
restart: unless-stopped
networks:
- monitoring
networks:
monitoring:
driver: bridge

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask
influxdb
python-socketio
flask-socketio
gunicorn
eventlet

76
static/css/style.css Normal file
View File

@@ -0,0 +1,76 @@
:root {
--bg-dark: #0d1117;
--card-bg: #161b22;
--border-color: #30363d;
--text-main: #c9d1d9;
--blue-accent: #58a6ff;
}
body {
background-color: var(--bg-dark);
color: var(--text-main);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 14px;
margin: 0;
overflow-x: hidden;
}
.gauge-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 15px;
}
.gauge-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 10px 5px;
text-align: center;
}
.gauge-canvas-container {
max-width: 80px;
margin: 0 auto;
}
.gauge-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--blue-accent);
margin-top: 2px;
}
.voltage-value {
font-size: 1.1rem;
font-weight: 800;
color: #ffffff;
}
.time-btn-container {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
gap: 6px;
padding-bottom: 10px;
justify-content: center;
}
.time-btn {
font-size: 0.75rem !important;
padding: 5px 12px !important;
white-space: nowrap;
border-color: var(--border-color) !important;
color: var(--blue-accent) !important;
}
.time-btn.active {
background-color: #1f6feb !important;
color: white !important;
border-color: #1f6feb !important;
}
.main-chart-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 15px;
height: 55vh;
min-height: 350px;
}
footer { padding: 20px 0; opacity: 0.7; }
@media (max-width: 576px) {
.voltage-value { font-size: 0.95rem; }
.main-chart-card { height: 50vh; padding: 10px; }
}

117
static/js/monitor.js Normal file
View File

@@ -0,0 +1,117 @@
const socket = io();
let currentTimeRange = '6h';
let phasesConfig = {};
const gauges = {};
let voltageChart = null;
const THRESHOLDS = { min: 207, max: 253 };
function initMonitor(phases, defaultRange) {
phasesConfig = phases;
currentTimeRange = defaultRange;
const gaugeConfig = {
type: 'doughnut',
data: {
datasets: [{
data: [0, 100],
backgroundColor: ['#198754', '#1a1d20'],
borderWidth: 0,
circumference: 180,
rotation: 270
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
cutout: '75%',
plugins: { legend: { display: false }, tooltip: { enabled: false } }
}
};
Object.keys(phasesConfig).forEach(id => {
const canvas = document.getElementById('gauge' + id);
if (canvas) {
gauges[id] = new Chart(canvas, JSON.parse(JSON.stringify(gaugeConfig)));
updateGaugeUI(id, 230);
}
});
const ctxChart = document.getElementById('voltageChart');
if (ctxChart) {
voltageChart = new Chart(ctxChart, {
type: 'line',
data: {
datasets: Object.keys(phasesConfig).map(id => ({
label: phasesConfig[id].label,
data: [],
borderColor: phasesConfig[id].color,
backgroundColor: phasesConfig[id].color + '20',
tension: 0.3,
pointRadius: 0,
borderWidth: 2
}))
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
time: {
displayFormats: { hour: 'HH:mm', minute: 'HH:mm' },
tooltipFormat: 'HH:mm'
},
grid: { color: '#2d3139' }
},
y: { min: 190, max: 270, grid: { color: '#2d3139' }, ticks: { stepSize: 10 } }
}
}
});
}
socket.on('voltage_update', function(data) {
Object.keys(phasesConfig).forEach(id => {
const val = data['phase' + id];
const textElement = document.getElementById('value' + id);
if (val !== undefined && val !== null && val !== 0) {
const numVal = parseFloat(val);
if (textElement) textElement.textContent = numVal.toFixed(1) + 'V';
updateGaugeUI(id, numVal);
}
});
if (data.timestamp) {
const date = new Date(data.timestamp);
document.getElementById('lastUpdate').textContent = 'Odczyt: ' + date.toLocaleTimeString('pl-PL', {hour: '2-digit', minute:'2-digit', second:'2-digit', hour12: false});
}
});
window.changeTimeRange(currentTimeRange);
}
function updateGaugeUI(id, val) {
if (!gauges[id]) return;
const percentage = Math.max(0, Math.min(100, ((val - 190) / 80) * 100));
let color = '#198754';
if (val < THRESHOLDS.min || val > THRESHOLDS.max) color = '#dc3545';
else if (val < 212 || val > 248) color = '#ffc107';
gauges[id].data.datasets[0].data = [percentage, 100 - percentage];
gauges[id].data.datasets[0].backgroundColor = [color, '#1a1d20'];
gauges[id].update('none');
}
window.changeTimeRange = async function(range) {
currentTimeRange = range;
document.querySelectorAll('.time-btn').forEach(btn => btn.classList.remove('active'));
const activeBtn = document.querySelector(`[data-range="${range}"]`);
if (activeBtn) activeBtn.classList.add('active');
if (!voltageChart) return;
for (let i = 1; i <= Object.keys(phasesConfig).length; i++) {
try {
const response = await fetch(`/api/timeseries/${i}?range=${range}`);
const data = await response.json();
voltageChart.data.datasets[i-1].data = data.map(d => ({ x: new Date(d.time), y: d.voltage }));
} catch (e) { console.error(e); }
}
voltageChart.update();
};

24
templates/base.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="pl" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>VoltMonitor</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="container-fluid py-3 px-2">
{% block content %}{% endblock %}
<footer class="text-center mt-4 py-3">
<small class="text-muted">
© {{ footer.year }} <a href="https://{{ footer.author }}" target="_blank" class="text-decoration-none">{{ footer.author }}</a>
</small>
</footer>
</div>
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

52
templates/index.html Normal file
View File

@@ -0,0 +1,52 @@
{% extends 'base.html' %}
{% block content %}
<header class="text-center mb-3">
<h5 class="mb-0">⚡ Sieć Trójfazowa</h5>
<span class="badge bg-dark border border-secondary text-muted" id="lastUpdate" style="font-size: 0.7rem;">Ładowanie...</span>
</header>
<!-- Gauge Section -->
<div class="gauge-grid mb-1">
{% for id, phase in phases.items() %}
<div class="gauge-card">
<div class="gauge-canvas-container">
<canvas id="gauge{{ id }}"></canvas>
</div>
<div class="gauge-label">{{ phase.label }}</div>
<div class="voltage-value" id="value{{ id }}">---</div>
</div>
{% endfor %}
</div>
<!-- Informacja o normie napięcia -->
<div class="text-center mb-3">
<span class="badge bg-dark border border-secondary text-muted" style="font-size: 0.65rem; font-weight: 400; opacity: 0.8;">
Norma PN-EN 50160: <span class="text-success">230V ±10% (207V - 253V)</span>
</span>
</div>
<!-- Time Selector -->
<div class="time-btn-container mb-3">
{% for key, r in time_ranges.items() %}
<button class="btn btn-sm btn-outline-primary time-btn {% if key == default_range %}active{% endif %}"
data-range="{{ key }}" onclick="changeTimeRange('{{ key }}')">
{{ key }}
</button>
{% endfor %}
</div>
<!-- Main Chart -->
<div class="main-chart-card mb-3">
<canvas id="voltageChart"></canvas>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/monitor.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
initMonitor({{ phases|tojson }}, '{{ default_range }}');
});
</script>
{% endblock %}