ExposedObjects — expose() API#

System ekspozycji obiektów do integracji zewnętrznych (MQTT/Home Assistant, HomeKit). Kluczowy mechanizm vCLU — łączy obiekty z rejestru z protokołami smart home.


Szybki start#

-- Najprostsze użycie — eksponuj obiekt z rejestru
local lamp = _:get("CLU.DOUT1")
expose(lamp, "switch", {name = "Lampa Salon", area = "Salon"})

-- Fluent API
expose(lamp, "switch"):name("Lampa"):readonly()

-- Eksponuj dimmer z zakresem
expose(dimmer, "dimmer", {name = "LED Kuchnia", min = 0, max = 100})

-- Eksponuj scenę
expose(myScene, "scene", {name = "Wieczór"})

-- Eksponuj wszystko z rejestru automatycznie
exposeRegistry()

expose(obj, type, options) — globalna funkcja#

local handle = expose(obj, objType, options)

Parametry:

  • obj (table) — obiekt z metodami get() (i opcjonalnie set(), onChange())
  • objType (string) — typ ekspozycji
  • options (table) — opcje konfiguracji (opcjonalne)

Zwraca: ExposedHandle — uchwyt do zarządzania wyeksponowanym obiektem


Typy ekspozycji#

TypOdczytZapisOpis
switchtaktakPrzełącznik ON/OFF (binarny)
lighttaktakŚwiatło z jasnością
dimmertaktakDimmer z zakresem 0–100
sensortaknieCzujnik binarny (read-only)
binary_sensortaknieCzujnik binarny (read-only)
motiontaknieCzujnik ruchu (zdarzeniowy)
temperaturetaknieTemperatura (-40–80 °C)
humiditytaknieWilgotność (0–100 %)
covertaktakRoleta/zasłona z pozycją 0–100
scenenienieScena (stateless, execute only)
numbertaktakWartość numeryczna z zakresem
buttonnieniePrzycisk (zdarzeniowy)
locktaktakZamek (binarny)
fantaktakWentylator z prędkością 0–100
climatetaktakKlimatyzacja z temperaturą 5–35
garage_doortaktakBrama garażowa (binarna)

Capabilities per typ#

switch    = { read=true, write=true, binary=true }
light     = { read=true, write=true, brightness=true }
dimmer    = { read=true, write=true, brightness=true, range={0,100} }
sensor    = { read=true, write=false, binary=true }
motion    = { read=true, write=false, binary=true, event=true }
temperature = { read=true, write=false, unit="°C", range={-40,80} }
humidity  = { read=true, write=false, unit="%", range={0,100} }
cover     = { read=true, write=true, position=true, range={0,100} }
scene     = { read=false, write=false, execute=true, stateless=true }
number    = { read=true, write=true, range={0,100} }
button    = { read=false, write=false, execute=true, event=true }
lock      = { read=true, write=true, binary=true }
fan       = { read=true, write=true, speed=true, range={0,100} }
climate   = { read=true, write=true, temperature=true, range={5,35} }
garage_door = { read=true, write=true, binary=true }

Opcje#

OpcjaTypDomyślnieOpis
namestringnilNazwa wyświetlana (zmieniana przez :rename())
pathstringnilCustom ścieżka (lockowana po pierwszym expose)
idstringnilID do generowania ścieżki (lockowane po pierwszym expose)
readonlybooleanfalseTylko odczyt — stan widoczny, sterowanie zablokowane
hiddenbooleanfalseUkryty — nie eksponowany (zwraca dummy handle)
mqttbooleantrueEkspozycja MQTT
homekitbooleantrueEkspozycja HomeKit
areastringnilSugerowany pokój w Home Assistant (suggested_area)
unitstringnilJednostka (np. "°C", "%")
minnumbernilMinimum zakresu
maxnumbernilMaximum zakresu
stepnumbernilKrok wartości (np. 0.5)
groupstringnilGrupa (przyszłe użycie)

ExposedHandle — API uchwytu#

Metody fluent (chainable)#

handle:name("Lampa Salon")      -- ustaw nazwę
handle:readonly()                -- oznacz read-only
handle:mqttOnly()                -- wyłącz HomeKit
handle:homekitOnly()             -- wyłącz MQTT
handle:group("salon")            -- ustaw grupę

Każda zwraca self — chainable:

expose(obj, "switch")
    :name("Lampa")
    :readonly()
    :mqttOnly()

Odczyt i zapis#

handle:getValue()               -- wywołuje obj:get(0), zwraca wartość lub nil
handle:setValue(value)           -- wywołuje obj:set(0, value), emituje state_changed
handle:getPath()                -- zwraca unikalną ścieżkę
handle:isExposed()              -- sprawdza czy wciąż wyeksponowany (boolean)

Aktualizacja runtime#

handle:update({readonly = true})   -- merge opcji, emituje object_updated
handle:rename("Nowa Nazwa")        -- zmiana nazwy, emituje object_updated
handle:unexpose()                   -- usuwa z eksponowanych (idempotent)

Wykonanie sceny#

handle:executeScene()
  • Wywołuje obj:execute(0) z ochroną debounce (300ms cooldown)
  • Zapobiega echo z HomeKit (scena wyzwolona natychmiast wysyła feedback)
  • Zwraca true jeśli wykonano, false jeśli zignorowano (cooldown)

Generowanie ścieżek (Path)#

Ścieżka jest generowana automatycznie z 5 priorytetami:

PriorytetŹródłoPrzykład
1Jawna opcja pathexpose(obj, "switch", {path = "my:lamp"})my:lamp
2Weak table (poprzedni expose)Ten sam obiekt → ta sama ścieżka
3Registry lookupObiekt z _registryCLU123.DOU456
4{pluginId}:{id|name}vclu:lamp_salon lub @vclu/weather:temperature
5{pluginId}:{hash}vclu:a1b2c3d4 (deterministyczny hash)

Lockowanie tożsamości#

Po pierwszym expose() ścieżka jest lockowana w weak table _objectToPath[obj] → path.

  • Kolejne expose() tego samego obiektu → idempotent (zwraca istniejący handle)
  • path i id w opcjach są ignorowane przy re-expose (z ostrzeżeniem)
  • name i inne opcje → aktualizowane normalnie

Kolizje#

SytuacjaZachowanie
Ten sam obiekt, ta sama ścieżkaZwraca istniejący handle (idempotent)
Inny obiekt, ta sama ścieżkaBłąd (logowany + exception)
hidden = trueZwraca dummy handle z _exposed = false

onChange — subskrypcja zmian#

Przy expose() system automatycznie rejestruje callback onChange:

-- Wewnętrznie (w expose):
local unsubscribe = obj:onChange(function(value)
    StateBus:getShared():emit("state_changed", {
        path = handle.path,
        value = value,
        type = handle.type,
    })
end)
handle._unsubscribe = unsubscribe

Wymagany interfejs obiektu:

function MyObject:onChange(callback)
    table.insert(self._callbacks, callback)
    -- MUSI zwrócić funkcję unsubscribe
    return function()
        for i, cb in ipairs(self._callbacks) do
            if cb == callback then table.remove(self._callbacks, i); break end
        end
    end
end

Przy unexpose() system wywołuje _unsubscribe() — cleanup bez wycieków pamięci.


Zdarzenia StateBus#

Expose emituje zdarzenia przez StateBus, które konsumują integracje (MQTT, HomeKit):

object_exposed#

StateBus:getShared():on("object_exposed", function(data)
    -- data: {path, type, name, options}
end)

Emitowane przez: expose()

state_changed#

StateBus:getShared():on("state_changed", function(data)
    -- data: {path, value, type}
end)

Emitowane przez: onChange callback, handle:setValue()

object_updated#

StateBus:getShared():on("object_updated", function(data)
    -- data: {path}
end)

Emitowane przez: handle:update(), handle:rename()

object_unexposed#

StateBus:getShared():on("object_unexposed", function(data)
    -- data: {path}
end)

Emitowane przez: handle:unexpose()


Integracja z MQTT / Home Assistant#

Topiki MQTT#

Wyeksponowane obiekty publikują stan do tematów MQTT:

vclu/exposed/{base}/{component}/{id}/state        -- stan (publikowany)
vclu/exposed/{base}/{component}/{id}/state/set     -- komendy (subskrybowany)
vclu/exposed/{base}/light/{id}/brightness          -- jasność (dimmer/light)
vclu/exposed/{base}/light/{id}/brightness/set      -- komenda jasności
vclu/exposed/{base}/cover/{id}/position            -- pozycja (cover)
vclu/exposed/{base}/cover/{id}/position/set         -- komenda pozycji
vclu/status                                         -- LWT: online/offline

Gdzie base i id to fragmenty ścieżki (split na :).

HA Discovery#

Automatyczna rejestracja w Home Assistant przez MQTT Discovery:

homeassistant/{component}/{uniqueId}/config

Payload Discovery (przykład switch):

{
  "name": "Lampa Salon",
  "unique_id": "vclu_lamp_salon",
  "object_id": "vclu_lamp_salon",
  "state_topic": "vclu/exposed/vclu/switch/lamp_salon/state",
  "command_topic": "vclu/exposed/vclu/switch/lamp_salon/state/set",
  "availability_topic": "vclu/status",
  "payload_available": "online",
  "payload_not_available": "offline",
  "device": {
    "identifiers": ["vclu"],
    "name": "Virtual CLU",
    "model": "Virtual CLU",
    "manufacturer": "Grenton"
  }
}

Formatowanie stanów (MQTT)#

TypWartość 0/falseWartość 1/trueInne
switch, sensor, motionOFFON
light, dimmerOFFON>0 = ON
sceneOFF (zawsze)Stateless
coverclosedopen0-100 = open
lockUNLOCKEDLOCKED
fanOFFON>0 = ON
garage_doorclosedopen
temperature, humidity, number, climateSurowa wartośćSurowa wartośćString numeryczny

Integracja z HomeKit#

Mapowanie typów na akcesoria#

Expose TypeHomeKit AccessorySterowanie
switchSwitchON/OFF → setValue(0|1)
lightLightbulbON/OFF → setValue(100|0)
dimmerLightbulbON/OFF + brightness → setValue(0-100)
coverWindowCoveringPozycja → setValue(0-100)
sensor, motionMotionSensorRead-only
temperatureTemperatureSensorRead-only
sceneSwitchStateless, zawsze OFF, triggers executeScene()
lockLockMechanismLOCKED/UNLOCKED
numberSwitch>0 = ON, ON→setValue(1), OFF→setValue(0)

Stabilność identyfikacji#

HomeKit używa SerialNumber = path — zapobiega duplikatom po restartach.


TypeMap — mapowanie typów#

Centralny moduł mapowania między typami Grenton, expose i Home Assistant:

Grenton → Expose#

Typ GrentonTyp Expose
DOUT, RemoteSwitchswitch
DIMMER, RemoteDimmerdimmer
LEDRGB, RemoteLightlight
ROLLER, ROLLER_SH, RemoteCovercover
DIN, RemoteSensorsensor
GPIO_DOUTswitch
GPIO_DINbinary_sensor
AnalogIN, AnalogOUTnumber
ONE_WIRE, PANELSENSTEMP, RemoteThermometertemperature

Expose → Home Assistant#

Typ ExposeHA Component
switchswitch
light, dimmerlight
sensor, motionbinary_sensor
temperature, humiditysensor
cover, garage_doorcover
sceneswitch
numbernumber
buttonbutton
locklock
fanfan
climateclimate

Funkcje TypeMap#

TypeMap.getExposeType(grentonType, default)        -- Grenton → expose
TypeMap.getHAComponent(exposeType, default)        -- expose → HA
TypeMap.getHAComponentFromGreton(grentonType)      -- Grenton → HA (legacy)
TypeMap.isValidExposeType(exposeType)              -- walidacja
TypeMap.getExposeTypes()                           -- lista typów expose
TypeMap.getHAComponents()                          -- lista komponentów HA

exposeRegistry(options) — auto-expose#

local count = exposeRegistry()
local count = exposeRegistry({area = "Dom"})

Automatycznie eksponuje wszystkie obiekty z _registry do MQTT/HomeKit.

Algorytm (dla każdego obiektu):

  1. Pomiń jeśli już ręcznie wyeksponowany (user.lua ma priorytet)
  2. Sprawdź AccessControl:
    • mqtt=hidden AND homekit=hidden → pomiń
    • mqtt=hidden → ustaw mqtt=false
    • homekit=hidden → ustaw homekit=false
    • mqtt=readonly AND homekit=readonly → ustaw readonly=true
  3. Określ typ z TypeMap.getExposeType(obj._type)
  4. Wywołaj expose() z opcjami

Priorytet ekspozycji:

PriorytetŹródłoZachowanie
1 (najwyższy)Ręczne expose() w user.luaNigdy nadpisywane
2AccessControl (access_control.yaml)Respektowane przez autoExpose
3exposeRegistry()Eksponuje resztę z domyślnymi ustawieniami

Zwraca: number — liczba nowo wyeksponowanych obiektów


Izolacja pluginów#

Każdy plugin operuje w swoim namespace:

-- Wewnętrznie przy ładowaniu pluginu:
ExposedObjects:setPluginContext("@vclu/weather")

-- expose() w kontekście pluginu:
expose(tempObj, "temperature", {id = "temperature"})
-- → ścieżka: "@vclu/weather:temperature"

-- Czyszczenie przy wyładowaniu:
ExposedObjects:unexposeByPlugin("@vclu/weather")
-- Usuwa wszystkie obiekty z prefiksem "@vclu/weather:"

API zarządzania#

ExposedObjects:list()                -- {path → handle} — wszystkie wyeksponowane
ExposedObjects:get(path)             -- handle lub nil
ExposedObjects:count()               -- liczba wyeksponowanych
ExposedObjects:getCapabilities(type) -- capabilities danego typu
ExposedObjects:toJSON(filter)        -- JSON (dla HomeKit: {homekit=true})

Pełny lifecycle#

EXPOSE
  expose(obj, "switch", {name: "Lamp"})
  → walidacja + generowanie ścieżki + lock tożsamości
  → rejestracja onChange callback
  → emit "object_exposed"
  → StatePublisher: MQTT Discovery + initial state
  → HomeKit: tworzenie akcesorium

ZMIANA STANU (obiekt)
  obj zmienia stan wewnętrznie
  → onChange callback → emit "state_changed"
  → StatePublisher: publikacja MQTT state topic
  → HomeKit: NotifyStateChange → update akcesorium

STEROWANIE ZDALNE (MQTT/HomeKit → obiekt)
  MQTT command topic lub HomeKit OnValueRemoteUpdate
  → ExposedObjects:get(path):setValue(value)
  → obj:set(0, value)
  → emit "state_changed" → propagacja do innych integracji

UNEXPOSE
  handle:unexpose()
  → cleanup onChange (_unsubscribe)
  → emit "object_unexposed"
  → StatePublisher: empty payload do discovery (HA usuwa entity)
  → HomeKit: akcesorium usunięte przy reload

Obsługa błędów#

-- Bezpieczne expose z pcall
local ok, handle = pcall(expose, obj, "switch", {name = "Test"})
if ok then
    print("Exposed: " .. handle:getPath())
else
    print("Error: " .. tostring(handle))
end

Typowe błędy:

SytuacjaKomunikat
obj = nil"object is nil"
Nieznany typ"unknown type 'xyz'. Valid types: ..."
Kolizja ścieżek"path 'x' already in use by another object"

Przykłady#

Ręczne expose obiektów z rejestru#

local lamp = _:get("CLU.DOUT1")
local dimmer = _:get("CLU.DIMM1")
local roller = _:get("CLU.ROLLER1")

expose(lamp, "switch", {name = "Lampa Salon", area = "Salon"})
expose(dimmer, "dimmer", {name = "LED Kuchnia", area = "Kuchnia"})
expose(roller, "cover", {name = "Roleta Sypialnia", area = "Sypialnia"})

Expose z tagami i grupami#

-- Taguj obiekty
_:get("CLU.DOUT1"):addTag("salon", "lights")
_:get("CLU.DOUT2"):addTag("salon", "lights")
_:get("CLU.DIMM1"):addTag("kuchnia", "lights")

-- Expose po tagu
_:byTag("lights"):expose("switch", {area = "Dom"})

Selektywna ekspozycja#

-- Tylko MQTT (bez HomeKit)
expose(sensor, "temperature", {name = "Temp zewn."}):mqttOnly()

-- Tylko HomeKit (bez MQTT)
expose(lamp, "switch", {name = "Lampa"}):homekitOnly()

-- Read-only sensor
expose(motion, "motion", {name = "Ruch korytarz"}):readonly()

Custom obiekt z onChange#

local myObj = {
    _value = 0,
    _callbacks = {},
}

function myObj:get(idx) return self._value end

function myObj:set(idx, val)
    local old = self._value
    self._value = val
    for _, cb in ipairs(self._callbacks) do cb(val) end
end

function myObj:onChange(cb)
    table.insert(self._callbacks, cb)
    return function()
        for i, c in ipairs(self._callbacks) do
            if c == cb then table.remove(self._callbacks, i); break end
        end
    end
end

expose(myObj, "number", {name = "Mój czujnik", min = 0, max = 100, unit = "%"})

Sceny#

local scenes = {
    evening = function()
        _:byTag("salon"):setValue(30)
        _:get("CLU.ROLLER1"):execute(0)  -- zamknij rolety
    end
}

for name, fn in pairs(scenes) do
    local sceneObj = {execute = function(self, idx) fn() end}
    expose(sceneObj, "scene", {name = name, id = name})
end