Lua-Defined Dashboards#
Dashboardy to publiczne strony sterowania dostępne pod /d/:id, zoptymalizowane pod tablety montowane na ścianie. Definiowane są w całości w Lua - bez YAML, bez DSL, czysty kod z callbackami.
Żaden inny kontroler smart home nie oferuje dashboardów definiowanych w Lua.
Szybki start#
Utwórz plik modules/dashboards/home.lua:
local r = _registry
Dashboard.new('home')
:name('Dom')
:icon('house-door')
:columns(3)
:auth('public')
:section('Salon', 'lamp')
:toggle('kanapa', 'Kanapa', 'lamp',
function() return r:get("CLU220000592.DOU0789"):getValue() end,
function(v)
local obj = r:get("CLU220000592.DOU0789")
if v then obj:switchOn() else obj:switchOff() end
end)
:build()Dodaj w modules/init.lua:
require("dashboards/home")Po reloadzie Lua dashboard jest dostępny pod http://vclu:8080/d/home.
Wymagane: Każdy nowy plik w
modules/musi być dodany domodules/init.luaprzezrequire(). Bez tego dashboard nie zostanie załadowany.
Builder API#
Dashboard definiuje się za pomocą wzorca builder z łańcuchowaniem metod:
Dashboard.new(id) -- utwórz builder (id = string, unikalny)
:name(string) -- nazwa wyświetlana
:icon(string) -- ikona Bootstrap Icons (bez prefiksu bi-)
:columns(number) -- liczba kolumn siatki (domyślnie 3)
:auth(string) -- 'public' | 'session' | 'pin'
:pin(string) -- PIN (wymagany gdy auth='pin')
:poll(number) -- interwał pollingu w sekundach (domyślnie 2)
:theme(table) -- tablica kolorów i tapety
:section(name, icon) -- rozpocznij sekcję (zwraca SectionHandle)
:build() -- zarejestruj dashboardDwa style definiowania#
Styl 1: Łańcuchowy - wszystko na jednym builderze:
Dashboard.new('home')
:name('Dom')
:columns(3)
:section('Salon', 'lamp')
:toggle('kanapa', 'Kanapa', 'lamp', getterFn, setterFn)
:button('movie', 'Movie', 'film', tapFn)
:section('Kuchnia', 'cup-hot')
:toggle('led', 'LED', 'lightbulb', getterFn, setterFn)
:build()Styl 2: Referencje - sekcje jako zmienne:
local dash = Dashboard.new('home'):name('Dom'):columns(3)
local salon = dash:section('Salon', 'lamp')
salon:toggle('kanapa', 'Kanapa', 'lamp', getterFn, setterFn)
salon:button('movie', 'Movie', 'film', tapFn)
local kuchnia = dash:section('Kuchnia', 'cup-hot')
kuchnia:toggle('led', 'LED', 'lightbulb', getterFn, setterFn)
dash:build()Oba style dają identyczny efekt. Styl 2 jest wygodniejszy przy większych dashboardach.
Typy widgetów#
button - przycisk#
Kliknij → wywołaj akcję. Brak stanu.
:button(id, label, icon, onTap, opts)salon:button('movie', 'Tryb filmowy', 'film', function()
r:get("scene:salon_movie"):execute()
end)toggle - przełącznik#
Przełącznik ON/OFF z aktualnym stanem.
:toggle(id, label, icon, getValue, onToggle, opts)salon:toggle('kanapa', 'Kanapa', 'lamp',
function() return r:get("CLU.DOU0789"):getValue() end,
function(v)
if v then r:get("CLU.DOU0789"):switchOn()
else r:get("CLU.DOU0789"):switchOff() end
end)slider - suwak#
Zakres wartości z min/max.
:slider(id, label, min, max, getValue, onSet, opts)salon:slider('dimmer', 'Dimmer', 0, 100,
function() return r:get("CLU.DIM1234"):getValue() end,
function(v) r:get("CLU.DIM1234"):setValue(v) end,
{ span = 2, color = 'warning' })status - wartość tylko do odczytu#
Wyświetla wartość + jednostkę. Brak interakcji.
:status(id, label, icon, getValue, unit, opts)temp:status('salon', 'Salon', 'thermometer-half',
function() return r:get("CLU.ANA4201"):getValue() end, '°C',
{ size = 'large' })link - odnośnik#
Kliknij → otwórz URL w nowej karcie. Brak stanu.
:link(id, label, icon, url, opts)salon:link('grafana', 'Grafana', 'graph-up', 'http://192.168.1.50:3000')chart - wykres#
Wykres liniowy z danymi historycznymi z systemu metryk. Dane pobierane bezpośrednio przez przeglądarkę z /api/metrics/data - bez obciążania Lua.
:chart(id, label, metricPath, opts)temps:chart('salon', 'Salon', 'temp:salon',
{unit = '°C', range = '2h', span = 2, color = '#FF6B6B'})
temps:chart('humidity', 'Wilgotność', 'salda:humidity',
{unit = '%', range = '24h', span = 2, fill = true})Opcje wykresu:
| Opcja | Typ | Opis |
|---|---|---|
range | string | Zakres czasu: '30m', '2h' (domyślnie), '24h', '7d' |
refresh | number | Interwał odświeżania w sekundach (domyślnie auto wg range) |
unit | string | Jednostka na osi Y i w tooltipie ('°C', '%', 'RPM') |
color | string | Kolor linii (hex lub nazwa: 'success', 'warning', 'info') |
span | number | Ile kolumn zajmuje widget (wykresy zwykle 2) |
min / max | number | Stałe granice osi Y (opcjonalne) |
fill | boolean | Wypełnij obszar pod linią (domyślnie false) |
Auto-refresh wg zakresu (gdy refresh nie ustawione):
| Range | Refresh |
|---|---|
30m | 30s |
2h | 60s |
24h | 5 min |
7d | 15 min |
Wykresy nie używają pollingu
getValues()- dane pobierane bezpośrednio z HTTP API metryk. To znaczy, że wykresy nie obciążają Lua runtime ani nie spowalniają normalnych widgetów.
cover - roleta / cover#
Kompaktowy widget do sterowania roletami: 3 przyciski kierunkowe (▲ ■ ▼) + opcjonalny suwak pozycji.
:cover(id, label, icon, fns, opts)local roleta = r:get("CLU220000592.ROL3129")
ctrl:cover('roleta_salon', 'Roleta salon', 'arrows-expand', {
getPosition = function() return roleta:getPosition() end,
getState = function() return roleta:getState() end, -- 0=stop, 1=opening, 2=closing
open = function() roleta:open() end,
close = function() roleta:close() end,
stop = function() roleta:stop() end,
set = function(v) roleta:setPosition(v) end, -- opcjonalne → włącza suwak
})Bez pozycji (brak set = brak suwaka):
ctrl:cover('markiza', 'Markiza', 'umbrella', {
getPosition = function() return 0 end,
open = function() markiza:open() end,
close = function() markiza:close() end,
stop = function() markiza:stop() end,
})Tabela fns:
| Callback | Wymagane | Opis |
|---|---|---|
getPosition | tak | Aktualna pozycja 0–100 |
getState | nie | Stan: 0=zatrzymany, 1=otwieranie, 2=zamykanie |
open | tak | Otwórz roletę |
close | tak | Zamknij roletę |
stop | tak | Zatrzymaj |
set | nie | Ustaw pozycję (0–100). Gdy podane → widoczny suwak |
Akcje: open, close, stop, set (number 0–100)
dimmer - ściemniacz#
Toggle ON/OFF + suwak jasności w jednym widgecie.
:dimmer(id, label, icon, fns, opts)local led = r:get("CLU220000592.DIM5044")
ctrl:dimmer('biuro_led', 'LED biuro', 'lamp', {
get = function() return led:getBrightness() end,
set = function(v) led:setBrightness(v) end,
switchOn = function() led:switchOn() end,
switchOff = function() led:switchOff() end,
isOn = function() return led:getValue() > 0 end,
})Minimalny (tylko set):
ctrl:dimmer('bulb', 'Żarówka', 'lightbulb', {
set = function(v) bulb:setBrightness(v) end,
})Tabela fns:
| Callback | Wymagane | Opis |
|---|---|---|
set | tak | Ustaw jasność 0–100 |
get | nie | Odczytaj jasność 0–100 |
switchOn | nie | Włącz (gdy oba z switchOff → widoczny toggle) |
switchOff | nie | Wyłącz |
isOn | nie | Czy włączony (fallback: get() > 0) |
Akcje: set (number 0–100), switchOn, switchOff
color - RGB picker#
Siatka presetów kolorów + opcjonalny suwak jasności + opcjonalny toggle.
:color(id, label, icon, fns, opts)ctrl:color('led_rgb', 'LED RGB', 'palette', {
getColor = function() return led:getHue(), led:getSaturation(), led:getBrightness() end,
setColor = function(r,g,b) led:setColor(r,g,b) end,
get = function() return led:getBrightness() end,
set = function(v) led:setBrightness(v) end,
switchOn = function() led:switchOn() end,
switchOff = function() led:switchOff() end,
isOn = function() return led:getValue() > 0 end,
}, {
presets = {
{label='Ciepła', r=255, g=180, b=100},
{label='Zimna', r=224, g=232, b=255},
{label='Czerw', r=255, g=68, b=68},
{label='Zieleń', r=68, g=255, b=68},
}
})Minimalny (tylko kolor):
ctrl:color('accent', 'Podświetlenie', 'palette', {
getColor = function() return accent:getHue(), accent:getSaturation(), accent:getBrightness() end,
setColor = function(r,g,b) accent:setColor(r,g,b) end,
})Gdy nie podasz presets, używanych jest 8 domyślnych kolorów (Warm White, Cool White, Red, Green, Blue, Purple, Orange, Cyan).
Tabela fns:
| Callback | Wymagane | Opis |
|---|---|---|
getColor | tak | Aktualny kolor {r, g, b} (0–255) |
setColor | tak | Ustaw kolor (r, g, b) |
get | nie | Jasność 0–100 (gdy oba z set → widoczny suwak) |
set | nie | Ustaw jasność 0–100 |
switchOn | nie | Włącz (gdy oba z switchOff → widoczny toggle) |
switchOff | nie | Wyłącz |
isOn | nie | Czy włączony |
Akcje: setColor ({r,g,b}), set (number 0–100), switchOn, switchOff
Compound widgets (cover, dimmer, color) używają tabeli
fnsz callbackami zamiast pozycyjnych argumentów. Capabilities (suwak pozycji, toggle, suwak jasności) są wykrywane automatycznie na podstawie tego, które funkcje są podane.
Sparkline na status#
Każdy widget status może wyświetlać mały wykres (sparkline) pod wartością. Wystarczy dodać spark w opcjach:
:status('salon', 'Salon', 'thermometer-half',
function() return r:get("CLU.ANA4201"):getValue() end, '°C',
{spark = 'temp:salon', sparkRange = '2h'})| Opcja | Typ | Opis |
|---|---|---|
spark | string | Ścieżka metryki (np. 'temp:salon') |
sparkRange | string | Zakres czasu (domyślnie '2h') |
Sparkline to miniaturowy wykres liniowy (32px wysokości) bez osi i tooltipów - tylko wizualna informacja o trendzie.
Opcje widgetów#
Ostatni argument każdego widgetu to opcjonalna tabela opts:
| Opcja | Typ | Opis |
|---|---|---|
span | number | Ile kolumn zajmuje widget (domyślnie 1) |
size | string | 'normal' (domyślnie) lub 'large' (większa ikona i tekst) |
color | string | Kolor: 'accent', 'success', 'warning', 'danger', 'info' lub hex '#ff6600' |
disabled | function | Callback zwracający true = widget wyszarzony, bez interakcji |
Przykłady:
-- Widget zajmujący 2 kolumny, duży, w kolorze warning
:slider('dimmer', 'Dimmer', 0, 100, getFn, setFn, { span = 2, size = 'large', color = 'warning' })
-- Widget wyszarzony gdy alarm jest aktywny
:button('open_gate', 'Brama', 'door-open', tapFn, {
disabled = function() return r:get("alarm"):isActive() end
})Autoryzacja#
Trzy tryby per-dashboard:
| Tryb | Opis |
|---|---|
public | Bez autoryzacji - każdy w sieci ma dostęp |
session | Wymaga zalogowania (sesja vCLU). Przekieruje na /login |
pin | Wymaga podania PIN-u. Formularz z klawiaturą numeryczną |
-- Dashboard publiczny
Dashboard.new('home'):auth('public'):build()
-- Dashboard z PIN-em
Dashboard.new('alarm'):auth('pin'):pin('1234'):build()
-- Dashboard dla zalogowanych
Dashboard.new('admin'):auth('session'):build()PIN jest weryfikowany po stronie serwera. Po poprawnym wpisaniu ustawiany jest cookie ważny 24h.
Theme i tapeta#
Tabela theme kontroluje kolory i tło dashboardu:
:theme({
accent = '#27C2A8', -- kolor akcentu (przyciski, slidery)
background = '#1a1a2e', -- kolor tła
cardBg = 'rgba(22, 33, 62, 0.85)', -- tło kart (rgba dla przezroczystości)
text = '#eee', -- kolor tekstu
wallpaper = '/static/img/bg.jpg', -- tapeta (URL)
})| Klucz | CSS variable | Opis |
|---|---|---|
accent | --dash-accent | Kolor akcentu - aktywne toggle, slidery, przyciski |
background | --dash-bg | Kolor tła strony |
cardBg | --dash-card-bg | Tło widgetów. Użyj rgba() z tapetą dla efektu glassmorphism |
text | --dash-text | Kolor tekstu |
wallpaper | --dash-wallpaper | URL obrazka tła (cover, fixed, center) |
Tapeta + rgba: Gdy używasz tapety, ustaw
cardBgnargba()z przezroczystością (np.0.85). Dzięki temu karty widgetów będą półprzezroczyste i tapeta będzie widoczna w tle.
Dostępne ikony#
Dashboardy używają Bootstrap Icons. Nazwa ikony to tekst po bi- - np. bi-lamp → 'lamp'.
Smart home - najczęściej używane#
| Kategoria | Ikony |
|---|---|
| Dom | house, house-door, house-fill |
| Światła | lamp, lamp-fill, lightbulb, lightbulb-fill, brightness-high |
| Zewnętrzne | sun, sun-fill, moon, moon-fill, moon-stars |
| Temperatura | thermometer-half, thermometer-high, thermometer-low, thermometer-sun |
| Woda | droplet, droplet-fill, moisture |
| Wentylacja | fan, wind |
| Ogrzewanie | fire |
| Przełączniki | toggle-on, toggle-off, power |
| Drzwi/bramy | door-open, door-closed, box-arrow-up, box-arrow-down |
| Garaż | car-front, truck |
| Bezpieczeństwo | camera-video, eye, shield-check, lock, unlock, key |
| Alarm | bell, bell-fill, exclamation-triangle |
| Gniazda | plug, outlet |
| Kuchnia | cup-hot, cup-hot-fill |
| Media | music-note, volume-up, speaker, film, tv, display |
| Biuro | pc-display, laptop |
| Ogród | tree, flower1, flower2 |
| Piętra | layers, stack |
| Zegar | clock, alarm, stopwatch |
| Sieć | wifi, broadcast, router |
| Wykresy | graph-up, bar-chart, activity |
Convenience methods#
Zamiast obj:execute(0) / obj:get(0) używaj czytelnych metod:
| Moduł | Metody |
|---|---|
| DOUT | switchOn(time), switchOff(time), toggle(time), getValue(), isOn() |
| DIN | getValue(), isActive() |
| ANALOG_OUT | setValue(val), getValue(), getPercent() |
| ANALOG_IN | getValue(), getPercent() |
| Timer | start(), stop(), pause(), isRunning() |
-- Zamiast:
r:get("CLU.DOU0789"):execute(1) -- co to robi? 🤷
r:get("CLU.DOU0789"):get(0) -- co zwraca? 🤷
-- Użyj:
r:get("CLU.DOU0789"):switchOn() -- jasne!
r:get("CLU.DOU0789"):getValue() -- jasne!
r:get("CLU.DOU0789"):getValue() == 1 -- jasne!Obsługa błędów#
Wszystkie callbacki getValue() i disabled() są owrapowane w pcall(). Gdy callback rzuci błąd:
- Widget wyświetla ikonę ostrzeżenia z tooltipem błędu
- Dashboard nie crashuje - pozostałe widgety działają normalnie
- W JSON API:
{"value": null, "error": "error message"}
Responsive#
- Desktop/tablet: siatka z liczbą kolumn ustawioną w
:columns(N) - Mobile (< 600px): automatycznie 2 kolumny
- Brak sidebara i topbara - pełny ekran na widgety
Zarządzanie#
Strona /dashboards w panelu vCLU (z sidebarem) pokazuje listę wszystkich zdefiniowanych dashboardów z linkami, liczbą widgetów i typem autoryzacji.
API#
| Endpoint | Metoda | Opis |
|---|---|---|
/d/:id | GET | Strona dashboardu (publiczna) |
/dashboards | GET | Panel zarządzania (wymaga logowania) |
/api/dashboards | GET | Lista dashboardów (JSON) |
/api/dashboard/:id | GET | Definicja + aktualne wartości |
/api/dashboard/:id/values | GET | Wartości widgetów (polling) |
/api/dashboard/:id/action | POST | Wykonaj akcję ({widgetId, action, value}) |
/api/dashboard/:id/auth | POST | Weryfikacja PIN ({pin}) |
Caching#
Wartości widgetów są cachowane po stronie Go z TTL = 1s. Wiele tabletów odpytujących ten sam dashboard generuje tylko 1 wywołanie Lua na sekundę, nie N.
Definicja dashboardu jest cachowana 60s i invalidowana przy reloadzie Lua.
Akcje (POST) nigdy nie są cachowane - natychmiastowe wykonanie.
Rate limiting#
Endpoint /api/dashboard/:id/action ma limit 10 żądań/sekundę per IP (token bucket). Chroni publiczne dashboardy przed nadużyciem.
Warunkowe dashboardy#
build() jest jawne - dashboard istnieje dopiero po wywołaniu build(). Możesz tworzyć dashboardy warunkowo:
if os.date("*t").hour >= 18 then
Dashboard.new('night'):name('Tryb nocny'):...:build()
endbuild() jest idempotentne - wielokrotne wywołanie na tym samym ID zastępuje poprzednią definicję.
Header bar#
Opcjonalny pasek na górze dashboardu z kompaktowymi widgetami (np. temperatury, status). Definiuje się go przez :header() przed pierwszą sekcją:
local dash = Dashboard.new('home'):name('Dom'):columns(3)
-- Header - kompaktowe wartości na górze
local hdr = dash:header()
hdr:status('h_salon', 'Salon', 'thermometer-half',
function() return r:get("CLU.ANA4201"):getValue() end, '°C')
hdr:status('h_kuchnia', 'Kuchnia', 'thermometer-half',
function() return r:get("CLU.ANA5151"):getValue() end, '°C')
-- Normalne sekcje
local salon = dash:section('Salon', 'lamp')
salon:toggle(...)
dash:build()Header wspiera widgety status i link. Widgety w headerze wyświetlają się w jednej linii, bez siatki.
Duplikaty ID#
build() waliduje unikalność ID widgetów w ramach dashboardu. Duplikat rzuca błąd Lua:
Duplicate widget ID "kanapa" in dashboard "home"Kompletny przykład#
Dashboard z wszystkimi typami widgetów:
--- modules/dashboards/home.lua
local r = _registry
local dash = Dashboard.new('home')
:name('Dom')
:icon('house-door')
:columns(3)
:auth('public')
:poll(2)
:theme({
accent = '#27C2A8',
background = '#1a1a2e',
cardBg = 'rgba(22, 33, 62, 0.85)',
text = '#eee',
})
-- Header: temperatury na szybko
local hdr = dash:header()
hdr:status('h_salon', 'Salon', 'thermometer-half',
function() return r:get("CLU.ANA4201"):getValue() end, '°C')
hdr:status('h_kuchnia', 'Kuchnia', 'thermometer-half',
function() return r:get("CLU.ANA5151"):getValue() end, '°C')
-- Salon: światła + tryb filmowy
local salon = dash:section('Salon', 'lamp')
salon:toggle('kanapa', 'Kanapa', 'lamp',
function() return r:get("CLU.DOU0789"):getValue() end,
function(v)
if v then r:get("CLU.DOU0789"):switchOn()
else r:get("CLU.DOU0789"):switchOff() end
end)
salon:toggle('stol', 'Stół', 'lamp',
function() return r:get("CLU.DOU5834"):getValue() end,
function(v)
if v then r:get("CLU.DOU5834"):switchOn()
else r:get("CLU.DOU5834"):switchOff() end
end)
salon:button('movie', 'Movie', 'film', function()
r:get("scene:salon_movie"):execute()
end)
-- Dimmer z suwakiem (zajmuje 2 kolumny)
salon:slider('dimmer', 'Jasność', 0, 100,
function() return r:get("CLU.DIM1234"):getValue() end,
function(v) r:get("CLU.DIM1234"):setValue(v) end,
{ span = 2, color = 'warning' })
-- Temperatura: status z wykresem sparkline
local temp = dash:section('Temperatura', 'thermometer-half')
temp:status('temp_salon', 'Salon', 'thermometer-half',
function() return r:get("CLU.ANA4201"):getValue() end, '°C',
{ size = 'large', spark = 'temp:salon', sparkRange = '2h' })
-- Wykresy temperatur (zajmują 2 kolumny każdy)
local charts = dash:section('Historia', 'clock-history')
charts:chart('c_salon', 'Salon (2h)', 'temp:salon',
{ unit = '°C', range = '2h', span = 2, color = '#FF6B6B' })
charts:chart('c_daily', 'Salon (24h)', 'temp:salon',
{ unit = '°C', range = '24h', span = 2, fill = true })
-- Linki
local links = dash:section('Zewnętrzne', 'link-45deg')
links:link('grafana', 'Grafana', 'graph-up', 'http://192.168.1.50:3000')
dash:build()Podsumowanie widgetów#
Proste widgety#
| Typ | Sygnatura | Akcja | Stan |
|---|---|---|---|
button | (id, label, icon, onTap, opts) | Kliknij → callback | Brak |
toggle | (id, label, icon, getValue, onToggle, opts) | Przełącz ON/OFF | Odczyt + zapis |
slider | (id, label, min, max, getValue, onSet, opts) | Ustaw wartość | Odczyt + zapis |
status | (id, label, icon, getValue, unit, opts) | Brak | Tylko odczyt |
link | (id, label, icon, url, opts) | Otwórz URL | Brak |
chart | (id, label, metricPath, opts) | Brak | Wykres historyczny |
Compound widgety#
Używają tabeli fns z callbackami. Capabilities wykrywane automatycznie.
| Typ | Sygnatura | UI | Capabilities |
|---|---|---|---|
cover | (id, label, icon, fns, opts) | ▲ ■ ▼ + suwak pozycji | set → suwak |
dimmer | (id, label, icon, fns, opts) | Toggle + suwak jasności | switchOn/Off → toggle |
color | (id, label, icon, fns, opts) | Presety + jasność + toggle | get+set → jasność, switchOn/Off → toggle |