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- Tworzysz plik
.luawmodules/ - Tworzysz obiekty za pomocą klas
Virtual* - Wywołujesz
expose()- obiekt pojawia się w Home Assistant, HomeKit i MCP - Reagujesz na zmiany stanów przez
onChange()ionEvent()
Pliki z modules/ ładują się automatycznie - init.lua pierwszy, potem reszta alfabetycznie.
Dostępne klasy#
| Klasa | _type | Expose type | Stan | Zastosowanie |
|---|---|---|---|---|
VirtualSwitch | DOUT | switch | 0/1 | Przekaźniki, zawory, pompy |
VirtualDimmer | DIMMER | dimmer | 0.0-1.0 | Oświetlenie z regulacją |
VirtualCover | ROLLER | cover | 0-100 | Rolety, bramy, zasłony |
VirtualSensor | DIN | sensor | 0/1 | Czujniki binarne (ruch, drzwi) |
VirtualNumber | AnalogOUT | number | dowolna | Temperatura, progi, wartości |
VirtualText | TEXT | text | string | Statusy, 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 callbackaCallback 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 HomeKitPrzykł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#
- Jeden moduł = jeden plik -
modules/garaz.lua,modules/pogoda.lua, nie wszystko winit.lua - Zmienne lokalne - używaj
localdla obiektów wewnątrz modułu (nie zanieczyszczaj globalnej przestrzeni) - Expose z metadanymi -
area,group,descriptionpomagają AI i dashboardowi - Nie blokuj - nigdy
while,repeat, busy-wait. Używajafter(),every(), callbacków - onChange zwraca unsubscribe - jeśli trzymasz referencje, pamiętaj o cleanup
- 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 dalejJeśli widzisz takie logi - zoptymalizuj callback lub podziel go na mniejsze kroki z
after().