Files
venus/dbus-generator-ramp/web_ui.py
dev 9756538f16 Initial commit: Venus OS boat addons monorepo
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
2026-03-16 17:04:16 +00:00

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()