push
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__
|
||||
*.pyc
|
||||
venv
|
||||
.env
|
||||
README.md
|
||||
.vscode
|
||||
.idea
|
||||
7
.gitignore
Normal file
7
.gitignore
Normal file
@@ -0,0 +1,7 @@
|
||||
__pycache__
|
||||
venv/
|
||||
env
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
9
Dockerfile
Normal file
9
Dockerfile
Normal 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"]
|
||||
86
app.py
Normal file
86
app.py
Normal 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
66
config.py
Normal 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
22
docker-compose.yml
Normal 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
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask
|
||||
influxdb
|
||||
python-socketio
|
||||
flask-socketio
|
||||
gunicorn
|
||||
eventlet
|
||||
76
static/css/style.css
Normal file
76
static/css/style.css
Normal 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
117
static/js/monitor.js
Normal 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
24
templates/base.html
Normal 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
52
templates/index.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user