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 metodamiget()(i opcjonalnieset(),onChange())objType(string) — typ ekspozycjioptions(table) — opcje konfiguracji (opcjonalne)
Zwraca: ExposedHandle — uchwyt do zarządzania wyeksponowanym obiektem
Typy ekspozycji#
| Typ | Odczyt | Zapis | Opis |
|---|---|---|---|
switch | tak | tak | Przełącznik ON/OFF (binarny) |
light | tak | tak | Światło z jasnością |
dimmer | tak | tak | Dimmer z zakresem 0–100 |
sensor | tak | nie | Czujnik binarny (read-only) |
binary_sensor | tak | nie | Czujnik binarny (read-only) |
motion | tak | nie | Czujnik ruchu (zdarzeniowy) |
temperature | tak | nie | Temperatura (-40–80 °C) |
humidity | tak | nie | Wilgotność (0–100 %) |
cover | tak | tak | Roleta/zasłona z pozycją 0–100 |
scene | nie | nie | Scena (stateless, execute only) |
number | tak | tak | Wartość numeryczna z zakresem |
button | nie | nie | Przycisk (zdarzeniowy) |
lock | tak | tak | Zamek (binarny) |
fan | tak | tak | Wentylator z prędkością 0–100 |
climate | tak | tak | Klimatyzacja z temperaturą 5–35 |
garage_door | tak | tak | Brama 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#
| Opcja | Typ | Domyślnie | Opis |
|---|---|---|---|
name | string | nil | Nazwa wyświetlana (zmieniana przez :rename()) |
path | string | nil | Custom ścieżka (lockowana po pierwszym expose) |
id | string | nil | ID do generowania ścieżki (lockowane po pierwszym expose) |
readonly | boolean | false | Tylko odczyt — stan widoczny, sterowanie zablokowane |
hidden | boolean | false | Ukryty — nie eksponowany (zwraca dummy handle) |
mqtt | boolean | true | Ekspozycja MQTT |
homekit | boolean | true | Ekspozycja HomeKit |
area | string | nil | Sugerowany pokój w Home Assistant (suggested_area) |
unit | string | nil | Jednostka (np. "°C", "%") |
min | number | nil | Minimum zakresu |
max | number | nil | Maximum zakresu |
step | number | nil | Krok wartości (np. 0.5) |
group | string | nil | Grupa (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
truejeśli wykonano,falsejeśli zignorowano (cooldown)
Generowanie ścieżek (Path)#
Ścieżka jest generowana automatycznie z 5 priorytetami:
| Priorytet | Źródło | Przykład |
|---|---|---|
| 1 | Jawna opcja path | expose(obj, "switch", {path = "my:lamp"}) → my:lamp |
| 2 | Weak table (poprzedni expose) | Ten sam obiekt → ta sama ścieżka |
| 3 | Registry lookup | Obiekt z _registry → CLU123.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) pathiidw opcjach są ignorowane przy re-expose (z ostrzeżeniem)namei inne opcje → aktualizowane normalnie
Kolizje#
| Sytuacja | Zachowanie |
|---|---|
| Ten sam obiekt, ta sama ścieżka | Zwraca istniejący handle (idempotent) |
| Inny obiekt, ta sama ścieżka | Błąd (logowany + exception) |
hidden = true | Zwraca 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 = unsubscribeWymagany 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
endPrzy 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/offlineGdzie base i id to fragmenty ścieżki (split na :).
HA Discovery#
Automatyczna rejestracja w Home Assistant przez MQTT Discovery:
homeassistant/{component}/{uniqueId}/configPayload 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)#
| Typ | Wartość 0/false | Wartość 1/true | Inne |
|---|---|---|---|
switch, sensor, motion | OFF | ON | — |
light, dimmer | OFF | ON | >0 = ON |
scene | OFF (zawsze) | — | Stateless |
cover | closed | open | 0-100 = open |
lock | UNLOCKED | LOCKED | — |
fan | OFF | ON | >0 = ON |
garage_door | closed | open | — |
temperature, humidity, number, climate | Surowa wartość | Surowa wartość | String numeryczny |
Integracja z HomeKit#
Mapowanie typów na akcesoria#
| Expose Type | HomeKit Accessory | Sterowanie |
|---|---|---|
switch | Switch | ON/OFF → setValue(0|1) |
light | Lightbulb | ON/OFF → setValue(100|0) |
dimmer | Lightbulb | ON/OFF + brightness → setValue(0-100) |
cover | WindowCovering | Pozycja → setValue(0-100) |
sensor, motion | MotionSensor | Read-only |
temperature | TemperatureSensor | Read-only |
scene | Switch | Stateless, zawsze OFF, triggers executeScene() |
lock | LockMechanism | LOCKED/UNLOCKED |
number | Switch | >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 Grenton | Typ Expose |
|---|---|
DOUT, RemoteSwitch | switch |
DIMMER, RemoteDimmer | dimmer |
LEDRGB, RemoteLight | light |
ROLLER, ROLLER_SH, RemoteCover | cover |
DIN, RemoteSensor | sensor |
GPIO_DOUT | switch |
GPIO_DIN | binary_sensor |
AnalogIN, AnalogOUT | number |
ONE_WIRE, PANELSENSTEMP, RemoteThermometer | temperature |
Expose → Home Assistant#
| Typ Expose | HA Component |
|---|---|
switch | switch |
light, dimmer | light |
sensor, motion | binary_sensor |
temperature, humidity | sensor |
cover, garage_door | cover |
scene | switch |
number | number |
button | button |
lock | lock |
fan | fan |
climate | climate |
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 HAexposeRegistry(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):
- Pomiń jeśli już ręcznie wyeksponowany (user.lua ma priorytet)
- Sprawdź AccessControl:
mqtt=hiddenANDhomekit=hidden→ pomińmqtt=hidden→ ustawmqtt=falsehomekit=hidden→ ustawhomekit=falsemqtt=readonlyANDhomekit=readonly→ ustawreadonly=true
- Określ typ z
TypeMap.getExposeType(obj._type) - Wywołaj
expose()z opcjami
Priorytet ekspozycji:
| Priorytet | Źródło | Zachowanie |
|---|---|---|
| 1 (najwyższy) | Ręczne expose() w user.lua | Nigdy nadpisywane |
| 2 | AccessControl (access_control.yaml) | Respektowane przez autoExpose |
| 3 | exposeRegistry() | 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 reloadObsł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))
endTypowe błędy:
| Sytuacja | Komunikat |
|---|---|
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