Custom modules#

Moduły custom to pliki Lua w katalogu modules/, które tworzą własne obiekty, eksponują je do Home Assistant / HomeKit, i reagują na zdarzenia. Nie potrzebujesz wizarda ani Object Managera - sam definiujesz co chcesz.

Jak to działa#

modules/garaz.lua → VirtualSwitch/VirtualNumber/... → expose() → MQTT / HomeKit / MCP
  1. Tworzysz plik .lua w modules/
  2. Tworzysz obiekty za pomocą klas Virtual*
  3. Wywołujesz expose() - obiekt pojawia się w Home Assistant, HomeKit i MCP
  4. Reagujesz na zmiany stanów przez onChange() i onEvent()

Pliki z modules/ ładują się automatycznie - init.lua pierwszy, potem reszta alfabetycznie.

Dostępne klasy#

Klasa_typeExpose typeStanZastosowanie
VirtualSwitchDOUTswitch0/1Przekaźniki, zawory, pompy
VirtualDimmerDIMMERdimmer0.0-1.0Oświetlenie z regulacją
VirtualCoverROLLERcover0-100Rolety, bramy, zasłony
VirtualSensorDINsensor0/1Czujniki binarne (ruch, drzwi)
VirtualNumberAnalogOUTnumberdowolnaTemperatura, progi, wartości
VirtualTextTEXTtextstringStatusy, etykiety

Wszystkie klasy dziedziczą z VirtualObject i mają wspólny interfejs: getValue(), onChange(), expose(), addTag(), emit().

API bazowe (VirtualObject)#

Każdy obiekt Virtual* dziedziczy te metody:

obj:getValue()              -- odczytaj stan
obj:updateState(value)      -- ustaw stan (odpala onChange + emit)
obj:onChange(fn)             -- callback na zmiane, zwraca unsubscribe
obj:onEvent(name, fn)       -- callback na zdarzenie
obj:emit(name, ...)         -- emituj zdarzenie
obj:expose(options)         -- eksponuj do HA/HomeKit/MCP
obj:addTag("critical")      -- dodaj tag
obj:hasTag("critical")      -- sprawdz tag
obj:getInfo()               -- {name, type, tags, methods, state}

onChange - kluczowa metoda#

onChange zwraca funkcję unsubscribe - to wymagane przez system expose:

local unsub = obj:onChange(function(newValue, oldValue)
    log.info("Zmiana: %s → %s", tostring(oldValue), tostring(newValue))
end)

-- Pozniej: unsub()  -- odpiecie callbacka

Callback odpala się tylko gdy wartość się zmieni - ustawienie tej samej wartości nie triggeruje.

expose - eksponowanie obiektu#

obj:expose({
    name = "Lampa garaz",
    area = "Garaz",
    group = "Oswietlenie"
})

Zwraca ExposedHandle z fluent API:

obj:expose({name = "Pompa"})
    :area("Kotlownia")
    :group("Ogrzewanie")
    :tags({"critical", "noAutoOff"})
    :description("Pompa CWU. Nie wlaczaj gdy kociol OFF.")
    :readonly()        -- tylko odczyt
    :mqttOnly()        -- bez HomeKit

Przykład 1: Garaż - dwie bramy i światła#

Kompletny moduł garażu: dwie bramy garażowe (cover), światło wewnętrzne (switch), światło zewnętrzne z timerem (switch), czujnik otwarcia (sensor).

modules/garaz.lua#

-- ============================================
-- Garaz - bramy, swiatla, czujnik
-- ============================================

-- Bramy
local bramaLewa = VirtualCover:new("BRAMA_LEWA")
local bramaPrawa = VirtualCover:new("BRAMA_PRAWA")

-- Swiatla
local swiatloWew = VirtualSwitch:new("SWIATLO_GARAZ")
local swiatloZew = VirtualSwitch:new("SWIATLO_PODJAZD")

-- Czujnik
local czujnikDrzwi = VirtualSensor:new("DRZWI_GARAZ")

-- ============================================
-- Expose
-- ============================================
bramaLewa:expose({name = "Brama lewa", area = "Garaz", group = "Bramy"})
    :description("Brama garażowa lewa. open=0, close=100.")

bramaPrawa:expose({name = "Brama prawa", area = "Garaz", group = "Bramy"})
    :description("Brama garażowa prawa.")

swiatloWew:expose({name = "Swiatlo garaz", area = "Garaz", group = "Oswietlenie"})
swiatloZew:expose({name = "Swiatlo podjazd", area = "Garaz", group = "Oswietlenie"})
    :description("Wylacza sie automatycznie po 10 min.")

czujnikDrzwi:expose({name = "Drzwi garaz", area = "Garaz"})
    :tags({"security"})
    :description("1 = otwarte, 0 = zamkniete")

-- ============================================
-- Automatyzacje
-- ============================================

-- Swiatlo podjazdu: auto-off po 10 minutach
local podjazdTimer = nil
swiatloZew:onChange(function(val)
    if podjazdTimer then cancel(podjazdTimer) end
    if val == 1 then
        podjazdTimer = after(10 * 60 * 1000, function()
            swiatloZew:switchOff()
            podjazdTimer = nil
        end)
    end
end)

-- Otwarcie bramy → wlacz swiatlo garazu
bramaLewa:onChange(function(pos, old)
    if old == 100 and pos < 100 then
        swiatloWew:switchOn()
    end
end)

bramaPrawa:onChange(function(pos, old)
    if old == 100 and pos < 100 then
        swiatloWew:switchOn()
    end
end)

-- Obie bramy zamkniete → wylacz swiatlo po 2 min
local function czyObieBramyZamkniete()
    return bramaLewa:getPosition() == 100 and bramaPrawa:getPosition() == 100
end

bramaLewa:onPositionChange(function()
    if czyObieBramyZamkniete() then
        after(2 * 60 * 1000, function()
            if czyObieBramyZamkniete() then
                swiatloWew:switchOff()
            end
        end)
    end
end)

-- Scena: zamknij garaz
scene("zamknij_garaz", function()
    bramaLewa:close()
    bramaPrawa:close()
    swiatloWew:switchOff()
    swiatloZew:switchOff()
end)

expose({execute = function(self) runScene("zamknij_garaz") end}, "scene", {
    name = "Zamknij garaz",
    path = "scene:zamknij_garaz",
    area = "Garaz"
}):description("Zamyka obie bramy i gasi swiatla.")

log.info("Modul garaz zaladowany")

Co dostajemy#

Po załadowaniu modułu:

  • 5 obiektów w Home Assistant / HomeKit (2 bramy, 2 światła, 1 czujnik)
  • 1 scena “Zamknij garaż”
  • Automatyczne światło podjazdu z timerem
  • Światło garażu reagujące na otwarcie bram
  • Pełna kontrola z MCP (AI może sterować bramami)

Przykład 2: Symulacja salonu#

Moduł modelujący salon z oświetleniem, roletą, temperaturą i trybem obecności. Przykład użycia różnych klas Virtual*.

modules/salon.lua#

-- ============================================
-- Salon - swiatla, roleta, temperatura, tryb
-- ============================================

-- Oswietlenie
local lampaSufit = VirtualDimmer:new("LAMPA_SUFIT")
local lampaKinkiet = VirtualDimmer:new("LAMPA_KINKIET")

-- Roleta
local roleta = VirtualCover:new("ROLETA_SALON")

-- Temperatura
local tempAktualna = VirtualNumber:new("TEMP_SALON", {
    min = -20, max = 50, step = 0.1, unit = "C"
})
local tempDocelowa = VirtualNumber:new("TEMP_SALON_TARGET", {
    min = 16, max = 28, step = 0.5, unit = "C", initial = 21
})

-- Status
local trybObecnosci = VirtualText:new("TRYB_SALON", {initial = "auto"})

-- ============================================
-- Expose
-- ============================================
lampaSufit:expose({name = "Sufit salon", area = "Salon", group = "Oswietlenie"})
lampaKinkiet:expose({name = "Kinkiet salon", area = "Salon", group = "Oswietlenie"})
roleta:expose({name = "Roleta salon", area = "Salon"})

tempAktualna:expose({name = "Temp. salon", area = "Salon", group = "Klimat"})
    :readonly()
    :description("Aktualna temperatura salonu. Tylko odczyt - aktualizowana z czujnika.")

tempDocelowa:expose({name = "Temp. docelowa salon", area = "Salon", group = "Klimat"})
    :description("Docelowa temperatura salonu. Zakres 16-28 C, krok 0.5.")

trybObecnosci:expose({name = "Tryb salon", area = "Salon"})
    :description("Tryb obecnosci: auto, home, away, night")

-- ============================================
-- Termostat - prosta logika
-- ============================================
local grzanieWlaczone = VirtualSwitch:new("GRZANIE_SALON")
grzanieWlaczone:expose({name = "Grzanie salon", area = "Salon", group = "Klimat"})
    :readonly()
    :description("Stan grzania. Sterowany automatycznie na podstawie temperatury.")

-- Sprawdzaj co minute
every(60 * 1000, function()
    local aktualna = tempAktualna:getValue()
    local docelowa = tempDocelowa:getValue()

    if aktualna < docelowa - 0.5 then
        if not grzanieWlaczone:isOn() then
            grzanieWlaczone:switchOn()
            log.info("Salon: grzanie ON (%.1f < %.1f)", aktualna, docelowa)
        end
    elseif aktualna > docelowa + 0.5 then
        if grzanieWlaczone:isOn() then
            grzanieWlaczone:switchOff()
            log.info("Salon: grzanie OFF (%.1f > %.1f)", aktualna, docelowa)
        end
    end
end)

-- ============================================
-- Sceny
-- ============================================
scene("film", function()
    lampaSufit:setBrightness(10)
    lampaKinkiet:setBrightness(30)
    roleta:close()
end)

scene("wieczor", function()
    lampaSufit:setBrightness(60)
    lampaKinkiet:setBrightness(80)
    roleta:close()
end)

scene("dzien", function()
    lampaSufit:switchOff()
    lampaKinkiet:switchOff()
    roleta:open()
end)

for _, nazwa in ipairs({"film", "wieczor", "dzien"}) do
    expose({execute = function(self) runScene(nazwa) end}, "scene", {
        name = nazwa:sub(1,1):upper() .. nazwa:sub(2),
        path = "scene:" .. nazwa,
        area = "Salon",
        group = "Sceny"
    })
end

-- ============================================
-- Tryb "away" - obniz temperature
-- ============================================
trybObecnosci:onChange(function(tryb)
    if tryb == "away" then
        tempDocelowa:setValue(18)
        lampaSufit:switchOff()
        lampaKinkiet:switchOff()
        log.info("Salon: tryb AWAY - temp 18, swiatla OFF")
    elseif tryb == "night" then
        tempDocelowa:setValue(19)
        lampaSufit:switchOff()
        lampaKinkiet:switchOff()
    end
end)

log.info("Modul salon zaladowany")

Przykład 3: Pogoda z MQTT#

Moduł czytający dane pogodowe z zewnętrznego źródła (np. Zigbee sensor, Shelly H&T) przez MQTT i eksponujący je jako obiekty vCLU.

modules/pogoda.lua#

-- ============================================
-- Pogoda - czujniki na MQTT
-- ============================================

local mqtt = MQTT:new("weather_sensors")
mqtt:setHost("localhost"):setPort(1883):connect()

-- Obiekty
local tempZew = VirtualNumber:new("TEMP_ZEW", {
    min = -40, max = 60, step = 0.1, unit = "C"
})
local wilgotnosc = VirtualNumber:new("WILGOTNOSC_ZEW", {
    min = 0, max = 100, step = 1, unit = "%"
})
local deszcz = VirtualSensor:new("DESZCZ")
local opis = VirtualText:new("POGODA_OPIS", {initial = "brak danych"})

-- Expose
tempZew:expose({name = "Temp. zewn.", area = "Ogrod", group = "Pogoda"})
    :readonly()

wilgotnosc:expose({name = "Wilgotnosc zewn.", area = "Ogrod", group = "Pogoda"})
    :readonly()

deszcz:expose({name = "Czujnik deszczu", area = "Ogrod", group = "Pogoda"})
    :tags({"weather"})

opis:expose({name = "Pogoda opis", area = "Ogrod", group = "Pogoda"})
    :readonly()
    :description("Tekstowy opis pogody, aktualizowany z czujnikow.")

-- ============================================
-- MQTT → obiekty
-- ============================================
mqtt:subscribe("sensors/outdoor/temperature", function(topic, payload)
    local val = tonumber(payload)
    if val then tempZew:updateState(val) end
end)

mqtt:subscribe("sensors/outdoor/humidity", function(topic, payload)
    local val = tonumber(payload)
    if val then wilgotnosc:updateState(val) end
end)

mqtt:subscribe("sensors/outdoor/rain", function(topic, payload)
    deszcz:updateState(payload == "1" and 1 or 0)
end)

-- ============================================
-- Logika - generuj opis tekstowy
-- ============================================
local function aktualizujOpis()
    local t = tempZew:getValue() or 0
    local h = wilgotnosc:getValue() or 0
    local d = deszcz:isActive()

    local tekst = string.format("%.1f C, %d%%", t, h)
    if d then tekst = tekst .. ", deszcz" end
    if t < 0 then tekst = tekst .. ", mroz!" end

    opis:setValue(tekst)
end

tempZew:onChange(aktualizujOpis)
wilgotnosc:onChange(aktualizujOpis)
deszcz:onChange(aktualizujOpis)

-- ============================================
-- Alert: mroz
-- ============================================
tempZew:onChange(function(temp)
    if temp < -5 then
        log.warn("Uwaga: mroz %.1f C!", temp)
    end
end)

log.info("Modul pogoda zaladowany")

Integracja z prawdziwym sprzętem#

Obiekty Virtual* same nie sterują sprzętem - to czyste obiekty stanowe w pamięci. Żeby połączyć je z prawdziwymi urządzeniami, łączymy je z MQTT, GPIO lub HTTP.

Wzorzec: VirtualSwitch + MQTT (Tasmota/Shelly)#

local pompa = VirtualSwitch:new("POMPA")
pompa:expose({name = "Pompa CWU", area = "Kotlownia"})

local mqtt = MQTT:new("pompa_mqtt")
mqtt:setHost("localhost"):setPort(1883):connect()

-- Stan z urzadzenia → obiekt
mqtt:subscribe("stat/pompa/POWER", function(topic, payload)
    pompa:updateState(payload == "ON" and 1 or 0)
end)

-- Komendy z obiektu → urzadzenie
pompa:onEvent("OnSwitchOn", function()
    mqtt:publish("cmnd/pompa/POWER", "ON")
end)
pompa:onEvent("OnSwitchOff", function()
    mqtt:publish("cmnd/pompa/POWER", "OFF")
end)

Wzorzec: VirtualNumber + HTTP (REST API)#

local temp = VirtualNumber:new("TEMP_ZEWN", {
    min = -40, max = 60, step = 0.1, unit = "C"
})
temp:expose({name = "Temperatura zewn.", area = "Ogrod"}):readonly()

-- Odpytuj API co 5 minut
every(5 * 60 * 1000, function()
    local http = HTTP:new()
    http:asyncGET("http://192.168.1.50/api/temperature", function(response)
        if response and response.status == 200 then
            local val = tonumber(response.body)
            if val then temp:updateState(val) end
        end
    end)
end)

Wzorzec: VirtualCover + GPIO#

-- Brama garażowa na przekaznikach GPIO
local relay_up = GPIO_DOUT:new("BRAMA_UP", 17, {activeLow = true})
local relay_down = GPIO_DOUT:new("BRAMA_DOWN", 27, {activeLow = true})
local sensor_open = GPIO_DIN:new("BRAMA_OPEN", 22)
local sensor_closed = GPIO_DIN:new("BRAMA_CLOSED", 23)

local brama = VirtualCover:new("BRAMA")
brama:expose({name = "Brama garaz", area = "Garaz"})

-- Komendy → GPIO
brama:onEvent("OnPositionChange", function(pos, old)
    if pos < old then
        -- otwieranie
        relay_down:switchOff()
        relay_up:switchOn()
        after(30000, function() relay_up:switchOff() end)
    elseif pos > old then
        -- zamykanie
        relay_up:switchOff()
        relay_down:switchOn()
        after(30000, function() relay_down:switchOff() end)
    end
end)

-- Sensory → stan bramy
sensor_open:onSwitchOn(function()
    brama:updateState(0)    -- otwarta
end)
sensor_closed:onSwitchOn(function()
    brama:updateState(100)  -- zamknieta
end)

Metryki (dashboard)#

Obiekty VirtualNumber mogą eksponować metryki na wykresach dashboardu:

local temp = VirtualNumber:new("TEMP_SALON", {min = -20, max = 50, step = 0.1, unit = "C"})

-- Rejestruj metryke - probkowanie co 30s
Metrics.register("temp:salon", function()
    return temp:getValue()
end, {unit = "C", interval = 30})

Metryka temp:salon będzie dostępna w dashboardzie jako wykres.


Struktura katalogów#

modules/
  init.lua          ← ladowany pierwszy (opcjonalny, np. wspolne stale)
  garaz.lua         ← modul garazu
  salon.lua         ← modul salonu
  pogoda.lua        ← modul pogody
  scenes/
    init.lua        ← podkatalogi ladowane recznie przez require()

Pliki bezpośrednio w modules/ ładują się automatycznie. Podkatalogi nie - musisz użyć require("scenes") w swoim kodzie. Podkatalog musi mieć init.lua.

Dobre praktyki#

  1. Jeden moduł = jeden plik - modules/garaz.lua, modules/pogoda.lua, nie wszystko w init.lua
  2. Zmienne lokalne - używaj local dla obiektów wewnątrz modułu (nie zanieczyszczaj globalnej przestrzeni)
  3. Expose z metadanymi - area, group, description pomagają AI i dashboardowi
  4. Nie blokuj - nigdy while, repeat, busy-wait. Używaj after(), every(), callbacków
  5. onChange zwraca unsubscribe - jeśli trzymasz referencje, pamiętaj o cleanup
  6. updateState vs setValue - updateState() to niskopoziomowe (nie waliduje), setValue() to publiczne API (waliduje, clampuje)

Lua w vCLU jest jednowątkowy. Jeden zły callback blokuje cały runtime - timery, MQTT, GPIO, dashboard. Runtime monitoruje czas callbacków i loguje ostrzeżenia:

  • >500ms - [TIMER] Slow callback (WARN)
  • >2s - [TIMER] Slow callback (ERROR)
  • >10s - [TIMER] KILLED callback - runtime przerywa callback i jedzie dalej

Jeśli widzisz takie logi - zoptymalizuj callback lub podziel go na mniejsze kroki z after().