Skip to content

Development Guide

ashipaek0 edited this page May 19, 2026 · 4 revisions

Development Guide – Epilykos

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.


Prerequisites

  • Node.js 18+
  • Epilykos installed (Docker or local)
  • JavaScript ES6+ (ES modules)
  • Async/await patterns
  • Basic SQL

Architecture Overview

Backend (modules/)

Module Responsibility
database.js SQLite connection, getConfig(), setConfig()
sessionAuth.js Session-based auth, CSRF, rate limiting
ha.js Home Assistant polling
mqtt.js MQTT broker connections
modbus.js Modbus TCP/Serial
external.js REST API polling
bms.js Bluetooth BMS bridge
history.js Legacy history snapshots
grid.js Grid status, timeline
solar.js Solar forecast (Solcast/Open-Meteo)
savings.js PV savings
metrics.js Current metrics, history
metricsManager.js User-created metrics CRUD
dashboard-config.js Layout config (JSON)
backup.js Database backup/restore
logger.js Winston logging
utils.js Grid state parser

Frontend (public/js/)

All ES modules via <script type="module">.

Module Purpose
main.js Entry — theme, config, WebSocket, polling
dashboard.js CSS Grid + SortableJS, per-block config
editor.js GridStack visual layout editor (/editor)
updater.js Dispatches updateWithState(state) to blocks
components/index.js componentBuilders registry (18 blocks)
components/*.js Block builders + updaters
api.js Centralised fetch
charts.js Chart.js with multi-instance Maps
forecast.js Multi-instance sparkline + weather
tables.js Multi-instance data tables
grid.js Grid card helpers
utils/blockId.js Block ID generation
utils/uid.js Scoped DOM ID helper

Data Flow

Polling (30s) → buildDashboardState() → WebSocket broadcast
    → Frontend updateWithState(state)
    → Each block updater reads state.metrics
Fallback: polling /api/dashboard-state every 60s

Dashboard Layout

  • 12-column CSS Grid with align-items: start (blocks keep independent heights)
  • colSpan — grid-column span (1–12). Default 12.
  • rowSpan — explicit height in px (0 = auto). Default 0.
  • bgColor — per-block background colour
  • transparent — per-block transparency toggle
  • SortableJS drag-to-reorder (auth-only, lock/unlock toggle)
  • Multi-instance — all blocks support duplicates via uid(base, blockId) scoped IDs

Editor (/editor)

  • GridStack visual layout designer
  • Drag blocks from palette, reposition, resize
  • Click ✕ to remove, + New / Delete / Rename dashboards
  • Save & Exit persists layout
  • Block positions saved as colSpan/rowSpan in dashboard config

Adding a New Block Type

Step 1: Create the Builder

Create public/js/components/myBlock.js:

import { uid } from '../utils/uid.js';

export function buildMyBlock(block = {}) {
  const id = block.id || '';
  const config = block.config || {};
  const metrics = config.metrics || {};
  const myMetric = metrics.value || 'default_metric';

  const container = document.createElement('div');
  container.className = 'my-block card';
  container.dataset.metricMap = JSON.stringify({ value: myMetric });
  container.dataset.blockId = id;

  container.innerHTML = `
    <h3>${escapeHtml(config.title || 'My Block')}</h3>
    <div class="my-value" data-metric="${escapeHtml(myMetric)}">--</div>
  `;
  return container;
}
function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

Pattern: Use uid(base, id) for all DOM IDs. Store metric config on dataset.metricMap. Use data-metric attributes. Provide defaults for backward compat.

Step 2: Create the Updater

export function updateMyBlock(state) {
  document.querySelectorAll('.my-block').forEach(container => {
    const id = container.dataset.blockId || '';
    let mm; try { mm = JSON.parse(container.dataset.metricMap); } catch(e) { return; }
    const m = state.metrics || {};
    const val = m[mm.value]?.value;
    if (val === undefined || val === null) return;
    const el = container.querySelector('.my-value');
    if (el) el.textContent = val.toFixed(1);
  });
}

Pattern: Iterate ALL containers of your type. Scope queries within each. Check for undefined/null values.

Step 3: Register

Edit components/index.js:

import { buildMyBlock, updateMyBlock } from './myBlock.js';
// Add to componentBuilders:
'my-block': buildMyBlock,

Step 4: Register Updater

Edit updater.js:

import { updateMyBlock } from './components/myBlock.js';
// Add to updateWithState:
if (activeLayout.some(b => b.type === 'my-block')) updateMyBlock(state);

Step 5: Register in Settings

Edit settings.js. Add to the type dropdown:

<option value="my-block">My Block</option>

Add config panel (in the config button handler):

} else if (block.type === 'my-block') {
  const currentMetrics = block.config?.metrics || {};
  configPanel.innerHTML = `
    <label>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>`;
}

Add save handler:

} else if (block.type === 'my-block') {
  block.config.title = configPanel.querySelector('.config-title')?.value || '';
  block.config.metrics = {};
  configPanel.querySelectorAll('.config-metric').forEach(s => { const r = s.dataset.role; if (s.value) block.config.metrics[r] = s.value; });
}

Step 6: Add CSS

Add to style.css:

.my-value { font-size: var(--fs-large); font-weight: 600; color: var(--text); }

Step 7: Add to Editor Palette

The editor (editor.js) auto-discovers all builders from componentBuilders. Add a display name:

const names = { 'my-block': 'My Block', ... };

Key Patterns

uid() Scoping

import { uid } from '../utils/uid.js';
const id = block.id || '';
// In HTML: id="${uid('my-element', id)}"
// In updater: document.getElementById(uid('my-element', id))

Metric Configurability

// Builder: store on container
container.dataset.metricMap = JSON.stringify(metrics);

// Updater: read from container
let mm; try { mm = JSON.parse(container.dataset.metricMap); } catch(e) { return; }
const val = state.metrics?.[mm.some_role]?.value;

Multi-Instance Updaters

export function updateMyBlock(state) {
  document.querySelectorAll('.my-block').forEach(container => {
    // scope all queries within container
    const id = container.dataset.blockId || '';
    // ...
  });
}

Per-Block Config

Each block in the dashboard editor has:

  • Type dropdown, Width dropdown, Height dropdown
  • Background colour picker (block.bgColor)
  • Transparency checkbox (block.transparent)
  • Config button for per-block settings (metrics, title, etc.)

Adding a New Data Source

Create modules/mySource.js following the pattern in external.js. Register in server.js. Add the config key to database.js essentials. Add settings UI in settings.html.


Coding Conventions

  • Use const for immutable values
  • escapeHtml() for user input in innerHTML
  • data-* attributes for metadata
  • JSON.stringify/parse for storing objects in data attributes
  • Check for undefined/null before displaying values
  • Polling functions never throw — log and continue
  • Chart.js instances stored in Maps keyed by canvas ID

Complete Example

See gaugeCard.js (full-circle SVG gauge), multiValueCard.js (unlimited metrics), textCard.js (static HTML content), iframeCard.js (URL embed), or flowCardSquare.js (2×2 grid layout) for complete examples of different block patterns.

Clone this wiki locally