Organizes 11 projects for Cerbo GX/Venus OS into a single repository: - axiom-nmea: Raymarine LightHouse protocol decoder - dbus-generator-ramp: Generator current ramp controller - dbus-lightning: Blitzortung lightning monitor - dbus-meteoblue-forecast: Meteoblue weather forecast - dbus-no-foreign-land: noforeignland.com tracking - dbus-tides: Tide prediction from depth + harmonics - dbus-vrm-history: VRM cloud history proxy - dbus-windy-station: Windy.com weather upload - mfd-custom-app: MFD app deployment package - venus-html5-app: Custom Victron HTML5 app fork - watermaker: Watermaker PLC control UI Adds root README, .gitignore, project template, and per-project .gitignore files. Sensitive config files excluded via .gitignore with .example templates provided. Made-with: Cursor
646 lines
22 KiB
Python
Executable File
646 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Simple Web UI for Generator Ramp Controller
|
|
|
|
Provides a browser-based interface for:
|
|
- Viewing current status
|
|
- Adjusting settings
|
|
- Monitoring power and ramp progress
|
|
|
|
Access at: http://<cerbo-ip>:8088
|
|
|
|
This runs as a separate service alongside the main controller.
|
|
It communicates via D-Bus to read/write settings.
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import logging
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
from urllib.parse import parse_qs, urlparse
|
|
import threading
|
|
|
|
# Add velib_python to path
|
|
sys.path.insert(1, os.path.join(os.path.dirname(__file__), 'ext', 'velib_python'))
|
|
sys.path.insert(1, '/opt/victronenergy/velib_python')
|
|
|
|
try:
|
|
import dbus
|
|
except ImportError:
|
|
print("ERROR: dbus-python not available")
|
|
sys.exit(1)
|
|
|
|
# Configuration
|
|
WEB_PORT = 8088
|
|
SERVICE_NAME = 'com.victronenergy.generatorramp'
|
|
|
|
logger = logging.getLogger('WebUI')
|
|
|
|
|
|
class DBusClient:
|
|
"""Client to read/write from our D-Bus service"""
|
|
|
|
def __init__(self):
|
|
self.bus = dbus.SystemBus()
|
|
|
|
def get_value(self, path):
|
|
"""Get a value from our service"""
|
|
try:
|
|
obj = self.bus.get_object(SERVICE_NAME, path, introspect=False)
|
|
return obj.GetValue(dbus_interface='com.victronenergy.BusItem')
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get {path}: {e}")
|
|
return None
|
|
|
|
def set_value(self, path, value):
|
|
"""Set a value on our service"""
|
|
try:
|
|
obj = self.bus.get_object(SERVICE_NAME, path, introspect=False)
|
|
obj.SetValue(value, dbus_interface='com.victronenergy.BusItem')
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to set {path}: {e}")
|
|
return False
|
|
|
|
def get_all_status(self):
|
|
"""Get all status values"""
|
|
paths = [
|
|
'/State',
|
|
'/CurrentLimit',
|
|
'/TargetLimit',
|
|
'/RecoveryTarget',
|
|
'/OverloadCount',
|
|
'/LastStableCurrent',
|
|
'/Power/L1',
|
|
'/Power/L2',
|
|
'/Power/Total',
|
|
'/Ramp/Progress',
|
|
'/Ramp/TimeRemaining',
|
|
'/Detection/Reversals',
|
|
'/Detection/StdDev',
|
|
'/Detection/IsOverload',
|
|
'/Generator/State',
|
|
'/AcInput/Connected',
|
|
'/Settings/InitialCurrent',
|
|
'/Settings/TargetCurrent',
|
|
'/Settings/RampDuration',
|
|
'/Settings/CooldownDuration',
|
|
'/Settings/Enabled',
|
|
]
|
|
|
|
result = {}
|
|
for path in paths:
|
|
value = self.get_value(path)
|
|
# Convert path to key (remove leading /)
|
|
key = path[1:].replace('/', '_')
|
|
result[key] = value
|
|
|
|
return result
|
|
|
|
|
|
# HTML Template
|
|
HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Generator Ramp Controller</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #1a1a2e;
|
|
color: #eee;
|
|
padding: 20px;
|
|
min-height: 100vh;
|
|
}
|
|
.container { max-width: 800px; margin: 0 auto; }
|
|
h1 {
|
|
color: #4fc3f7;
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
h1::before { content: "⚡"; }
|
|
|
|
.card {
|
|
background: #16213e;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
|
}
|
|
.card h2 {
|
|
color: #4fc3f7;
|
|
margin-bottom: 15px;
|
|
font-size: 1.1em;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.status-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 15px;
|
|
}
|
|
.status-item {
|
|
background: #0f3460;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
}
|
|
.status-item .label {
|
|
font-size: 0.8em;
|
|
color: #888;
|
|
margin-bottom: 5px;
|
|
}
|
|
.status-item .value {
|
|
font-size: 1.5em;
|
|
font-weight: bold;
|
|
color: #4fc3f7;
|
|
}
|
|
.status-item.warning .value { color: #ffa726; }
|
|
.status-item.error .value { color: #ef5350; }
|
|
.status-item.success .value { color: #66bb6a; }
|
|
|
|
.state-badge {
|
|
display: inline-block;
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
font-weight: bold;
|
|
font-size: 1.2em;
|
|
}
|
|
.state-idle { background: #455a64; }
|
|
.state-warmup { background: #ff9800; color: #000; }
|
|
.state-ramping { background: #2196f3; }
|
|
.state-cooldown { background: #9c27b0; }
|
|
.state-recovery { background: #ff5722; }
|
|
.state-stable { background: #4caf50; }
|
|
|
|
.progress-bar {
|
|
height: 20px;
|
|
background: #0f3460;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
margin-top: 10px;
|
|
}
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #4fc3f7, #66bb6a);
|
|
transition: width 0.5s ease;
|
|
}
|
|
|
|
.settings-form {
|
|
display: grid;
|
|
gap: 15px;
|
|
}
|
|
.form-group {
|
|
display: grid;
|
|
grid-template-columns: 1fr 120px;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.form-group label {
|
|
color: #aaa;
|
|
}
|
|
.form-group input, .form-group select {
|
|
background: #0f3460;
|
|
border: 1px solid #4fc3f7;
|
|
color: #fff;
|
|
padding: 10px;
|
|
border-radius: 6px;
|
|
font-size: 1em;
|
|
}
|
|
.form-group input:focus {
|
|
outline: none;
|
|
border-color: #66bb6a;
|
|
}
|
|
|
|
.btn {
|
|
background: #4fc3f7;
|
|
color: #000;
|
|
border: none;
|
|
padding: 12px 24px;
|
|
border-radius: 6px;
|
|
font-size: 1em;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
.btn:hover { background: #81d4fa; }
|
|
.btn:disabled { background: #455a64; cursor: not-allowed; }
|
|
.btn-danger { background: #ef5350; color: #fff; }
|
|
.btn-danger:hover { background: #e57373; }
|
|
.btn-success { background: #66bb6a; }
|
|
.btn-success:hover { background: #81c784; }
|
|
|
|
.power-display {
|
|
display: flex;
|
|
justify-content: space-around;
|
|
text-align: center;
|
|
padding: 10px 0;
|
|
}
|
|
.power-item .value {
|
|
font-size: 2em;
|
|
font-weight: bold;
|
|
color: #ffa726;
|
|
}
|
|
.power-item .label {
|
|
font-size: 0.9em;
|
|
color: #888;
|
|
}
|
|
|
|
.detection-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
padding: 10px;
|
|
background: #0f3460;
|
|
border-radius: 8px;
|
|
}
|
|
.detection-indicator {
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
background: #66bb6a;
|
|
}
|
|
.detection-indicator.overload {
|
|
background: #ef5350;
|
|
animation: pulse 0.5s infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.refresh-info {
|
|
text-align: center;
|
|
color: #666;
|
|
font-size: 0.8em;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.enabled-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.toggle-switch {
|
|
position: relative;
|
|
width: 60px;
|
|
height: 30px;
|
|
}
|
|
.toggle-switch input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
.toggle-slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: #455a64;
|
|
border-radius: 30px;
|
|
transition: 0.3s;
|
|
}
|
|
.toggle-slider::before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 22px;
|
|
width: 22px;
|
|
left: 4px;
|
|
bottom: 4px;
|
|
background: white;
|
|
border-radius: 50%;
|
|
transition: 0.3s;
|
|
}
|
|
.toggle-switch input:checked + .toggle-slider {
|
|
background: #66bb6a;
|
|
}
|
|
.toggle-switch input:checked + .toggle-slider::before {
|
|
transform: translateX(30px);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Generator Ramp Controller</h1>
|
|
|
|
<!-- Current State -->
|
|
<div class="card">
|
|
<h2>Current State</h2>
|
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
|
|
<span id="state-badge" class="state-badge state-idle">Idle</span>
|
|
<div class="enabled-toggle">
|
|
<span>Controller:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="enabled-toggle" onchange="toggleEnabled()">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
<span id="enabled-text">Enabled</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status-grid" style="margin-top: 20px;">
|
|
<div class="status-item">
|
|
<div class="label">Current Limit</div>
|
|
<div class="value" id="current-limit">--</div>
|
|
</div>
|
|
<div class="status-item">
|
|
<div class="label">Target</div>
|
|
<div class="value" id="target-limit">--</div>
|
|
</div>
|
|
<div class="status-item">
|
|
<div class="label">Overload Count</div>
|
|
<div class="value" id="overload-count">0</div>
|
|
</div>
|
|
<div class="status-item">
|
|
<div class="label">Generator</div>
|
|
<div class="value" id="generator-state">--</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress Bar -->
|
|
<div style="margin-top: 20px;">
|
|
<div style="display: flex; justify-content: space-between;">
|
|
<span>Ramp Progress</span>
|
|
<span id="progress-text">0%</span>
|
|
</div>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" id="progress-bar" style="width: 0%"></div>
|
|
</div>
|
|
<div style="text-align: right; color: #888; font-size: 0.9em; margin-top: 5px;">
|
|
Time remaining: <span id="time-remaining">--</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Power Monitoring -->
|
|
<div class="card">
|
|
<h2>Power Monitoring</h2>
|
|
<div class="power-display">
|
|
<div class="power-item">
|
|
<div class="value" id="power-l1">0</div>
|
|
<div class="label">L1 (W)</div>
|
|
</div>
|
|
<div class="power-item">
|
|
<div class="value" id="power-l2">0</div>
|
|
<div class="label">L2 (W)</div>
|
|
</div>
|
|
<div class="power-item">
|
|
<div class="value" id="power-total">0</div>
|
|
<div class="label">Total (W)</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detection-status" style="margin-top: 15px;">
|
|
<div class="detection-indicator" id="overload-indicator"></div>
|
|
<div>
|
|
<div>Overload Detection: <span id="detection-status">Normal</span></div>
|
|
<div style="color: #888; font-size: 0.9em;">
|
|
Reversals: <span id="reversals">0</span> |
|
|
Std Dev: <span id="std-dev">0</span>W
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Settings -->
|
|
<div class="card">
|
|
<h2>Settings</h2>
|
|
<form class="settings-form" onsubmit="saveSettings(event)">
|
|
<div class="form-group">
|
|
<label>Initial Current (A)</label>
|
|
<input type="number" id="initial-current" min="10" max="100" step="1" value="40">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Target Current (A)</label>
|
|
<input type="number" id="target-current" min="10" max="100" step="1" value="50">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Ramp Duration (min)</label>
|
|
<input type="number" id="ramp-duration" min="1" max="120" step="1" value="30">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Cooldown Duration (min)</label>
|
|
<input type="number" id="cooldown-duration" min="1" max="30" step="1" value="5">
|
|
</div>
|
|
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
|
<button type="submit" class="btn btn-success">Save Settings</button>
|
|
<button type="button" class="btn" onclick="loadStatus()">Refresh</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="refresh-info">
|
|
Auto-refreshes every 2 seconds | Last update: <span id="last-update">--</span>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const STATE_NAMES = ['Idle', 'Warm-up', 'Ramping', 'Cooldown', 'Recovery', 'Stable'];
|
|
const STATE_CLASSES = ['idle', 'warmup', 'ramping', 'cooldown', 'recovery', 'stable'];
|
|
const GEN_STATES = {0: 'Stopped', 1: 'Running', 2: 'Warm-up', 3: 'Cool-down', 10: 'Error'};
|
|
|
|
async function loadStatus() {
|
|
try {
|
|
const resp = await fetch('/api/status');
|
|
const data = await resp.json();
|
|
updateUI(data);
|
|
} catch (e) {
|
|
console.error('Failed to load status:', e);
|
|
}
|
|
}
|
|
|
|
function updateUI(data) {
|
|
// State
|
|
const state = data.State || 0;
|
|
const stateBadge = document.getElementById('state-badge');
|
|
stateBadge.textContent = STATE_NAMES[state] || 'Unknown';
|
|
stateBadge.className = 'state-badge state-' + (STATE_CLASSES[state] || 'idle');
|
|
|
|
// Enabled
|
|
const enabled = data.Settings_Enabled;
|
|
document.getElementById('enabled-toggle').checked = enabled;
|
|
document.getElementById('enabled-text').textContent = enabled ? 'Enabled' : 'Disabled';
|
|
|
|
// Status values
|
|
document.getElementById('current-limit').textContent =
|
|
(data.CurrentLimit || 0).toFixed(1) + 'A';
|
|
document.getElementById('target-limit').textContent =
|
|
(data.TargetLimit || 0).toFixed(1) + 'A';
|
|
document.getElementById('overload-count').textContent = data.OverloadCount || 0;
|
|
document.getElementById('generator-state').textContent =
|
|
GEN_STATES[data.Generator_State] || 'Unknown';
|
|
|
|
// Progress
|
|
const progress = data.Ramp_Progress || 0;
|
|
document.getElementById('progress-bar').style.width = progress + '%';
|
|
document.getElementById('progress-text').textContent = progress + '%';
|
|
|
|
const remaining = data.Ramp_TimeRemaining || 0;
|
|
const mins = Math.floor(remaining / 60);
|
|
const secs = remaining % 60;
|
|
document.getElementById('time-remaining').textContent =
|
|
remaining > 0 ? `${mins}m ${secs}s` : '--';
|
|
|
|
// Power
|
|
document.getElementById('power-l1').textContent = Math.round(data.Power_L1 || 0);
|
|
document.getElementById('power-l2').textContent = Math.round(data.Power_L2 || 0);
|
|
document.getElementById('power-total').textContent = Math.round(data.Power_Total || 0);
|
|
|
|
// Detection
|
|
const isOverload = data.Detection_IsOverload;
|
|
const indicator = document.getElementById('overload-indicator');
|
|
indicator.className = 'detection-indicator' + (isOverload ? ' overload' : '');
|
|
document.getElementById('detection-status').textContent = isOverload ? 'OVERLOAD!' : 'Normal';
|
|
document.getElementById('reversals').textContent = data.Detection_Reversals || 0;
|
|
document.getElementById('std-dev').textContent = (data.Detection_StdDev || 0).toFixed(1);
|
|
|
|
// Settings
|
|
document.getElementById('initial-current').value = data.Settings_InitialCurrent || 40;
|
|
document.getElementById('target-current').value = data.Settings_TargetCurrent || 50;
|
|
document.getElementById('ramp-duration').value = data.Settings_RampDuration || 30;
|
|
document.getElementById('cooldown-duration').value = data.Settings_CooldownDuration || 5;
|
|
|
|
// Timestamp
|
|
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
|
|
}
|
|
|
|
async function toggleEnabled() {
|
|
const enabled = document.getElementById('enabled-toggle').checked;
|
|
try {
|
|
await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({'/Settings/Enabled': enabled ? 1 : 0})
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to toggle enabled:', e);
|
|
}
|
|
}
|
|
|
|
async function saveSettings(event) {
|
|
event.preventDefault();
|
|
const settings = {
|
|
'/Settings/InitialCurrent': parseFloat(document.getElementById('initial-current').value),
|
|
'/Settings/TargetCurrent': parseFloat(document.getElementById('target-current').value),
|
|
'/Settings/RampDuration': parseInt(document.getElementById('ramp-duration').value),
|
|
'/Settings/CooldownDuration': parseInt(document.getElementById('cooldown-duration').value),
|
|
};
|
|
|
|
try {
|
|
const resp = await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(settings)
|
|
});
|
|
if (resp.ok) {
|
|
alert('Settings saved!');
|
|
loadStatus();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to save settings:', e);
|
|
alert('Failed to save settings');
|
|
}
|
|
}
|
|
|
|
// Initial load and auto-refresh
|
|
loadStatus();
|
|
setInterval(loadStatus, 2000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
|
|
class WebHandler(BaseHTTPRequestHandler):
|
|
"""HTTP request handler"""
|
|
|
|
dbus_client = None
|
|
|
|
def log_message(self, format, *args):
|
|
logger.debug(f"{self.address_string()} - {format % args}")
|
|
|
|
def send_json(self, data, status=200):
|
|
self.send_response(status)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(data).encode())
|
|
|
|
def send_html(self, html):
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/html')
|
|
self.end_headers()
|
|
self.wfile.write(html.encode())
|
|
|
|
def do_GET(self):
|
|
parsed = urlparse(self.path)
|
|
|
|
if parsed.path == '/':
|
|
self.send_html(HTML_TEMPLATE)
|
|
|
|
elif parsed.path == '/api/status':
|
|
if self.dbus_client is None:
|
|
self.dbus_client = DBusClient()
|
|
status = self.dbus_client.get_all_status()
|
|
self.send_json(status)
|
|
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def do_POST(self):
|
|
parsed = urlparse(self.path)
|
|
|
|
if parsed.path == '/api/settings':
|
|
content_length = int(self.headers.get('Content-Length', 0))
|
|
body = self.rfile.read(content_length)
|
|
|
|
try:
|
|
settings = json.loads(body)
|
|
if self.dbus_client is None:
|
|
self.dbus_client = DBusClient()
|
|
|
|
for path, value in settings.items():
|
|
self.dbus_client.set_value(path, value)
|
|
|
|
self.send_json({'status': 'ok'})
|
|
except Exception as e:
|
|
logger.error(f"Failed to save settings: {e}")
|
|
self.send_json({'error': str(e)}, 500)
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def do_OPTIONS(self):
|
|
self.send_response(200)
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
self.end_headers()
|
|
|
|
|
|
def run_server():
|
|
"""Run the web server"""
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(levelname)s %(name)s: %(message)s'
|
|
)
|
|
|
|
server = HTTPServer(('0.0.0.0', WEB_PORT), WebHandler)
|
|
logger.info(f"Web UI running at http://0.0.0.0:{WEB_PORT}")
|
|
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
logger.info("Shutting down web server")
|
|
server.shutdown()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
run_server()
|