-
Notifications
You must be signed in to change notification settings - Fork 0
Development Guide
This guide explains how to extend Epilykos with new visual blocks, data sources, and metrics. Intended for developers comfortable with JavaScript, Node.js, SQLite, and basic CSS/HTML.
- Node.js 18+
- Epilykos installed (Docker or local)
- JavaScript ES6+ (ES modules)
- Async/await patterns
- Basic SQL (optional)
- Familiar with HTML/CSS
| Module | Responsibility |
|---|---|
database.js |
SQLite connection, getConfig(), setConfig()
|
sessionAuth.js |
Session-based auth, CSRF, rate limiting |
ha.js |
Home Assistant polling, entity fetching |
mqtt.js |
MQTT broker connections, topic subscriptions |
modbus.js |
Modbus TCP/Serial polling |
external.js |
External REST API polling |
bms.js |
Bluetooth BMS bridge polling |
history.js |
Populate legacy history table from latest_metrics
|
grid.js |
Grid status polling, timeline, uptime hours |
solar.js |
Solar forecast (Solcast/Open-Meteo), power integration |
savings.js |
PV savings (today/week/month/all-time) |
metrics.js |
Current metrics, metric history |
metricsManager.js |
User-created metrics CRUD |
dashboard-config.js |
Dashboard layout (JSON storage) |
backup.js |
Database backup/restore |
logger.js |
Winston structured logging |
utils.js |
Grid state parser |
All ES modules loaded via <script type="module">.
| Module | Purpose |
|---|---|
main.js |
Entry point — theme, config load, WebSocket, polling fallback |
dashboard.js |
CSS Grid layout + SortableJS drag-to-reorder |
updater.js |
updateWithState(state) dispatches to all active block updaters |
components/index.js |
componentBuilders registry |
components/*.js |
Individual block builders + updaters |
api.js |
Centralised fetch calls |
charts.js |
Chart.js lifecycle, range selectors, configurable datasets |
theme.js |
Light/dark mode |
forecast.js |
Solar forecast sparkline + weather rendering |
tables.js |
Lazy-load data tables |
grid.js |
Grid card helpers (timeline, formatting) |
utils/blockId.js |
Block ID generation |
Backend Polling (30s interval)
→ buildDashboardState()
→ broadcast via WebSocket to all clients
→ Frontend updateWithState(state)
→ Each active block's updater reads state.metrics
Fallback: WebSocket fails → setInterval polls /api/dashboard-state every 60s
Blocks live in a 12-column CSS Grid (#dashboard-container). Each block has:
-
colSpan— grid-column span (1–12, defaults to 12). Controls width. -
rowSpan— optionalmin-heightin px (0 = auto, content-determined). -
config.metrics— per-block metric name overrides (see below).
Drag-to-reorder: SortableJS enables drag-reorder on the dashboard for authenticated users. Order persists on drop.
Block wrappers (.dashboard-block) are transparent flex columns. Each component handles its own card styling internally.
A block is a reusable dashboard widget. Adding one involves frontend changes only — no database modifications.
Create public/js/components/myBlock.js:
export function buildMyBlock(block = {}) {
const config = block.config || {};
const metrics = config.metrics || {};
// Optional: extract configurable metric names with defaults
const myMetric = metrics.my_value || 'default_metric_name';
const container = document.createElement('div');
container.className = 'my-block card';
container.style.background = 'var(--card-bg)';
container.style.borderRadius = 'var(--radius)';
container.style.padding = '1rem';
container.style.boxShadow = 'var(--shadow)';
container.style.border = '1px solid var(--border)';
container.dataset.metricMap = JSON.stringify({ value: myMetric });
container.innerHTML = `
<h3 style="margin:0 0 0.5rem 0;">${escapeHtml(config.title || 'My Block')}</h3>
<div class="my-block-value" data-metric="${escapeHtml(myMetric)}">--</div>
`;
return container;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}Pattern: Store metric names on container.dataset.metricMap as JSON. Use data-metric attributes on value elements. Provide sensible defaults for backward compatibility.
Edit public/js/components/index.js:
import { buildMyBlock } from './myBlock.js';
export const componentBuilders = {
// ...existing...
'my-block': buildMyBlock
};In public/js/components/myBlock.js:
export function updateMyBlock(state) {
const container = document.querySelector('.my-block');
if (!container) return;
let metricsMap;
try {
metricsMap = JSON.parse(container.dataset.metricMap);
} catch (e) { return; }
const metricName = metricsMap.value;
if (!metricName) return;
const val = state.metrics?.[metricName]?.value;
if (val !== undefined && val !== null) {
const el = container.querySelector('.my-block-value');
if (el) el.textContent = val.toFixed(1);
}
}The state object contains:
-
state.metrics—{ metricName: { value, timestamp } }— all latest metrics -
state.current— latest power values in kW -
state.savings— computed savings -
state.gridStatus/state.gridHours/state.gridTimeline— grid data -
state.powerHistory— 24h power time series -
state.dailyEnergyBar— 7d energy time series
Edit public/js/updater.js:
import { updateMyBlock } from './components/myBlock.js';
export function updateWithState(state) {
const activeLayout = dashboardConfig.dashboards.find(
db => db.id === dashboardConfig.activeDashboard
)?.layout || [];
// ...existing updates...
if (activeLayout.some(b => b.type === 'my-block')) {
updateMyBlock(state);
}
}Edit public/settings.js, find renderDashboardBlockEditor() and add to the type dropdown:
<option value="my-block">My Block</option>Also add a config panel. In the config button handler:
} else if (block.type === 'my-block') {
const currentMetrics = block.config?.metrics || {};
configPanel.innerHTML = `
<label>Block Title</label>
<input type="text" class="config-title" value="${escapeHtml(block.config?.title || '')}" style="width:100%; margin-bottom:0.5rem;">
<label>Value Metric</label>
<select class="config-metric" data-role="value" style="width:100%; margin-bottom:0.5rem;">${generateMetricOptionsHtml(currentMetrics.value)}</select>
<button class="fetch-btn save-config">Save</button>`;
}And in the save handler:
} else if (block.type === 'my-block') {
block.config.title = configPanel.querySelector('.config-title')?.value || '';
block.config.metrics = {};
configPanel.querySelectorAll('.config-metric').forEach(select => {
const role = select.dataset.role;
if (select.value) block.config.metrics[role] = select.value;
});
}The generateMetricOptionsHtml() helper is already available in settings.js — it populates <select> elements from the global allMetrics list.
Add to public/style.css:
.my-block-value {
font-size: var(--fs-large);
font-weight: 600;
color: var(--text);
}If your data comes from Home Assistant, MQTT, Modbus, or external REST API:
- Go to Settings → appropriate device section
- Click + Add Metric Mapping
- Enter metric name (e.g.,
grid_frequency) - Map to entity ID / MQTT topic / Modbus register / JSON path
- Click Save All Settings
The backend immediately starts collecting. The metric appears in latest_metrics and is available to all blocks.
Create modules/mySource.js:
const { getConfig, getDb } = require('./database');
const { logger } = require('./logger');
async function pollMySource() {
const sources = JSON.parse(getConfig('my_sources') || '[]');
if (!sources.length) return;
const db = getDb();
const metricInsert = db.prepare(
'INSERT OR IGNORE INTO metrics (timestamp, metric, value) VALUES (?, ?, ?)'
);
const latestUpsert = db.prepare(
'INSERT OR REPLACE INTO latest_metrics (metric, value, timestamp) VALUES (?, ?, ?)'
);
for (const src of sources) {
if (!src.enabled) continue;
try {
const data = await fetchFromMySource(src);
const now = Math.floor(Date.now() / 1000);
for (const [metric, value] of Object.entries(data)) {
if (value !== undefined && !isNaN(value)) {
metricInsert.run(now, metric, value);
latestUpsert.run(metric, value, now);
}
}
} catch (err) {
logger.error(`MySource ${src.name} error: ${err.message}`);
}
}
}
function startMySourcePolling() {
setInterval(pollMySource, 60000);
pollMySource();
}
module.exports = { startMySourcePolling };Register in server.js:
const { startMySourcePolling } = require('./modules/mySource');
// After other polling setup:
startMySourcePolling();Add the config key to database.js essentials list:
const essentialKeys = [ ..., 'my_sources' ];Add settings UI in public/settings.html and public/settings.js (follow the external sources pattern).
/api/dashboard-state returns all data in one request. Built by buildDashboardState() in server.js.
To add custom data, extend buildDashboardState():
async function buildDashboardState() {
// ...existing queries...
const myExtraData = db.prepare('SELECT ...').all();
return {
// ...existing fields...
myExtraData
};
}Then in your updater: state.myExtraData.
Prefer extending the aggregated endpoint over creating new API routes — it keeps WebSocket broadcasts efficient.
The backend broadcasts new state after every polling cycle (30s). Frontend receives it and calls updateWithState(state).
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'dashboard-state') {
updateWithState(message.data);
}
};WebSocket closed → automatic fallback to 60s polling via updateAllComponents().
If your data source updates asynchronously (e.g., MQTT message received), trigger a broadcast:
const state = await buildDashboardState();
broadcastDashboardState(state);No additional client-side work needed — blocks benefit from real-time updates automatically.
// Use textContent for user data (not innerHTML)
element.textContent = userInput;
// Escape HTML when innerHTML is unavoidable
element.innerHTML = `<div>${escapeHtml(userInput)}</div>`;
// Use parameterised SQL queries
db.prepare('SELECT * FROM metrics WHERE metric = ?').all(metricName);// Named exports
export function buildMyBlock(block = {}) { ... }
export function updateMyBlock(state) { ... }
// Lazy imports for heavy modules
const module = await import('./charts.js');
// Store config on DOM for updater access
container.dataset.metricMap = JSON.stringify(metrics);
// Check for element existence before updating
const el = document.getElementById('my-element');
if (!el) return;
// Check value exists before displaying
const val = state.metrics?.[name]?.value;
if (val === undefined || val === null) return;// Lazy DB access
const { getDb } = require('./database');
const db = getDb();
// Cache prepared statements
let cachedStmt = null;
function getMyStmt() {
if (!cachedStmt) cachedStmt = getDb().prepare('SELECT ...');
return cachedStmt;
}
// Polling functions never throw — log and continue
async function pollMySource() {
try { /* ... */ }
catch (err) { logger.error('Poll error:', err.message); }
}export function buildFrequencyCard(block = {}) {
const config = block.config || {};
const metrics = config.metrics || {};
const freqMetric = metrics.frequency || 'grid_frequency';
const container = document.createElement('div');
container.className = 'stat-card';
container.dataset.metricMap = JSON.stringify({ frequency: freqMetric });
container.innerHTML = `
<div class="stat-label">${escapeHtml(config.title || 'Grid Frequency')}</div>
<div class="stat-value" data-metric="${escapeHtml(freqMetric)}">-- Hz</div>
`;
return container;
}
export function updateFrequencyCard(state) {
const container = document.querySelector('.stat-card');
if (!container) return;
let metricsMap;
try { metricsMap = JSON.parse(container.dataset.metricMap); }
catch (e) { return; }
const freq = state.metrics?.[metricsMap.frequency]?.value;
if (freq === undefined) return;
const el = container.querySelector('.stat-value');
if (el) {
el.textContent = `${freq.toFixed(2)} Hz`;
el.style.color = (freq >= 47.5 && freq <= 52.5) ? '#51cf66' : '#ff6b6b';
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}import { buildFrequencyCard } from './frequencyCard.js';
// ...
'frequency-card': buildFrequencyCard,import { updateFrequencyCard } from './components/frequencyCard.js';
// ...
if (activeLayout.some(b => b.type === 'frequency-card')) updateFrequencyCard(state);Follow the pattern in Step 5 of "Adding a New Block Type" above.
Go to Settings → data source → + Add Metric Mapping → metric name grid_frequency → map to entity/topic/register.
- Builder registered in
components/index.js? -
<option>added insettings.jstype dropdown? - Hard refresh browser (Ctrl+Shift+R)?
- No console errors?
- Block
enabled !== falsein config?
- Reverse proxy may not support WebSocket upgrade.
- Dashboard still works — falls back to 60s polling.
- Metric names are case-sensitive.
- Check
state.metricsin browser console.
Edit charts.js — add a range button, extend the data fetch in refreshPowerChart() or refreshEnergyChart().
Yes — the builder runs, the updater can be empty. It still appears and re-renders on tab switches.
- Main README — setup, features, configuration reference
- GitHub Issues — bug reports, feature requests
Happy coding!