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 do modules/init.lua przez require(). 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 dashboard

Dwa 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' })

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:

OpcjaTypOpis
rangestringZakres czasu: '30m', '2h' (domyślnie), '24h', '7d'
refreshnumberInterwał odświeżania w sekundach (domyślnie auto wg range)
unitstringJednostka na osi Y i w tooltipie ('°C', '%', 'RPM')
colorstringKolor linii (hex lub nazwa: 'success', 'warning', 'info')
spannumberIle kolumn zajmuje widget (wykresy zwykle 2)
min / maxnumberStałe granice osi Y (opcjonalne)
fillbooleanWypełnij obszar pod linią (domyślnie false)

Auto-refresh wg zakresu (gdy refresh nie ustawione):

RangeRefresh
30m30s
2h60s
24h5 min
7d15 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:

CallbackWymaganeOpis
getPositiontakAktualna pozycja 0–100
getStatenieStan: 0=zatrzymany, 1=otwieranie, 2=zamykanie
opentakOtwórz roletę
closetakZamknij roletę
stoptakZatrzymaj
setnieUstaw 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:

CallbackWymaganeOpis
settakUstaw jasność 0–100
getnieOdczytaj jasność 0–100
switchOnnieWłącz (gdy oba z switchOff → widoczny toggle)
switchOffnieWyłącz
isOnnieCzy 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:

CallbackWymaganeOpis
getColortakAktualny kolor {r, g, b} (0–255)
setColortakUstaw kolor (r, g, b)
getnieJasność 0–100 (gdy oba z set → widoczny suwak)
setnieUstaw jasność 0–100
switchOnnieWłącz (gdy oba z switchOff → widoczny toggle)
switchOffnieWyłącz
isOnnieCzy włączony

Akcje: setColor ({r,g,b}), set (number 0–100), switchOn, switchOff

Compound widgets (cover, dimmer, color) używają tabeli fns z 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'})
OpcjaTypOpis
sparkstringŚcieżka metryki (np. 'temp:salon')
sparkRangestringZakres 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:

OpcjaTypOpis
spannumberIle kolumn zajmuje widget (domyślnie 1)
sizestring'normal' (domyślnie) lub 'large' (większa ikona i tekst)
colorstringKolor: 'accent', 'success', 'warning', 'danger', 'info' lub hex '#ff6600'
disabledfunctionCallback 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:

TrybOpis
publicBez autoryzacji - każdy w sieci ma dostęp
sessionWymaga zalogowania (sesja vCLU). Przekieruje na /login
pinWymaga 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)
})
KluczCSS variableOpis
accent--dash-accentKolor akcentu - aktywne toggle, slidery, przyciski
background--dash-bgKolor tła strony
cardBg--dash-card-bgTło widgetów. Użyj rgba() z tapetą dla efektu glassmorphism
text--dash-textKolor tekstu
wallpaper--dash-wallpaperURL obrazka tła (cover, fixed, center)

Tapeta + rgba: Gdy używasz tapety, ustaw cardBg na rgba() 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#

KategoriaIkony
Domhouse, house-door, house-fill
Światłalamp, lamp-fill, lightbulb, lightbulb-fill, brightness-high
Zewnętrznesun, sun-fill, moon, moon-fill, moon-stars
Temperaturathermometer-half, thermometer-high, thermometer-low, thermometer-sun
Wodadroplet, droplet-fill, moisture
Wentylacjafan, wind
Ogrzewaniefire
Przełącznikitoggle-on, toggle-off, power
Drzwi/bramydoor-open, door-closed, box-arrow-up, box-arrow-down
Garażcar-front, truck
Bezpieczeństwocamera-video, eye, shield-check, lock, unlock, key
Alarmbell, bell-fill, exclamation-triangle
Gniazdaplug, outlet
Kuchniacup-hot, cup-hot-fill
Mediamusic-note, volume-up, speaker, film, tv, display
Biuropc-display, laptop
Ogródtree, flower1, flower2
Piętralayers, stack
Zegarclock, alarm, stopwatch
Siećwifi, broadcast, router
Wykresygraph-up, bar-chart, activity

Convenience methods#

Zamiast obj:execute(0) / obj:get(0) używaj czytelnych metod:

ModułMetody
DOUTswitchOn(time), switchOff(time), toggle(time), getValue(), isOn()
DINgetValue(), isActive()
ANALOG_OUTsetValue(val), getValue(), getPercent()
ANALOG_INgetValue(), getPercent()
Timerstart(), 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#

EndpointMetodaOpis
/d/:idGETStrona dashboardu (publiczna)
/dashboardsGETPanel zarządzania (wymaga logowania)
/api/dashboardsGETLista dashboardów (JSON)
/api/dashboard/:idGETDefinicja + aktualne wartości
/api/dashboard/:id/valuesGETWartości widgetów (polling)
/api/dashboard/:id/actionPOSTWykonaj akcję ({widgetId, action, value})
/api/dashboard/:id/authPOSTWeryfikacja 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()
end

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

TypSygnaturaAkcjaStan
button(id, label, icon, onTap, opts)Kliknij → callbackBrak
toggle(id, label, icon, getValue, onToggle, opts)Przełącz ON/OFFOdczyt + zapis
slider(id, label, min, max, getValue, onSet, opts)Ustaw wartośćOdczyt + zapis
status(id, label, icon, getValue, unit, opts)BrakTylko odczyt
link(id, label, icon, url, opts)Otwórz URLBrak
chart(id, label, metricPath, opts)BrakWykres historyczny

Compound widgety#

Używają tabeli fns z callbackami. Capabilities wykrywane automatycznie.

TypSygnaturaUICapabilities
cover(id, label, icon, fns, opts)▲ ■ ▼ + suwak pozycjiset → suwak
dimmer(id, label, icon, fns, opts)Toggle + suwak jasnościswitchOn/Off → toggle
color(id, label, icon, fns, opts)Presety + jasność + toggleget+set → jasność, switchOn/Off → toggle