Rekuperator Salda przez Gate HTTP

Salda http
Widok strony internetowej generowanej przez moduł MB-GATEWAY od Salda.

Większość sprzętu można zintegrować z Grentonem przez przekaźniki, część przez Modbus. Ja natomiast, chciałem pokazać jak można się zintegrować z rekuperatorem przez Gate HTTP. W przypadku sprzętów posiadających bramkę HTTP można przeprowadzić prostą inżynierię wsteczną.

W tym przypadku, rekuperator posiada dwa wejścia Modbus i pod jedno podłączona jest bramka http od producenta – Salda. W drugie wejście można wpiąć Gate Modbus i sterować rekuperatorem, ale nie o tym jest ten post.

Posiadam “Gateway” od Saldy (link), który pozwala na sterowanie rekuperatorem za pomocą aplikacji na telefon. Dodatkowo moduł MB-GATEWAY udostępnia stronę WWW przez, którą można zarządzać samym modułem HTTP jak i podstawowymi parametrami rekuperatora. Komunikacja z telefonem odbywa się za pomocą protokołu opartego o TCP/IP na porcie 502. Jako, że Gate HTTP nie pozwala na operacje na socketach, to już nie sprawdzałem jak wygląda komunikacja dla aplikacji mobilnej.

Sprawdzenie możliwości Gate od Salda

Po wejściu na stronę, za pomocą inspektora można podejrzeć, co jest wysyłane.

Można znaleźć kilka typów wywołań np.:

1) GET http://192.168.0.71/FUNC(4,1,1,0,45)?160909905818

które zwraca:

0;0;1;0;0;0;0;0;0;0;0;0;1;1;1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;1;0;0;0;0;

2) GET http://192.168.0.71/FUNC(4,1,3,0,111)?1609098962765

zwraca:

1;22;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;

3) GET http://192.168.0.71/FUNC(4,1,3,111,110)?1609098985056

zwraca:

0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;

4) GET http://192.168.0.71/FUNC(4,1,3,330,15)?1609098905483

zwraca:

0;330;0;1000;100;0;0;0;5;20;0;0;53;0;0;

5) GET http://192.168.0.71/FUNC(4,1,4,0,24)?1609087597747

zwraca:

218;0;0;195;0;0;44;0;0;7;0;0;-718;27;1;30;30;0;3;2;542;97;0;0;

Po chwili można namierzyć, że request numer 5 zawiera najwięcej interesujących danych. Stworzyłem, lokalnie na komputerze plik rekuperator.lua, w którym zaczynam pisać wstępną obsługę danych z rekuperatora.

-- funkcja rozdzielająca string na tablicę
 
local function split(s, delimiter)
    result = {};
    for match in (s..delimiter):gmatch("(.-)"..delimiter) do
        table.insert(result, match);
    end
    return result;
end

local result = split(response, ";")

local supply_air = result[1]
local exhaust_air = result[7]
local extract_air = result[4]
local outside_air = result[10]
local water_temperature = result[13]
local humidity = result[14]
local supply_fan_speed = result[16]
local extract_fan_speed = result[17]

-- temperatury pomnożone są przez 10
print("supply_air: " .. supply_air / 10)
print("exhaust_air: " .. exhaust_air / 10)
print("extract_air: " .. extract_air / 10)
print("outside_air: " .. outside_air / 10)
print("water_temperature: " ..water_temperature / 10);
print("humidity: " .. humidity);
print("supply_fan_speed: " .. supply_fan_speed);

W ten sposób udało się zdobyć większość danych, które mogą się przydać. Rekuperator ma 4 biegi: 1, 2, 3, 4. Co jest oddane w procentach. Bieg pierwszy to 30%, drugi 60%, trzeci 80% i max 100%.
Brakuje jedynie temperatury jaką rekuperator stara się utrzymać w budynku. Znalazłem ją w żądaniu nr 2, czyli:

1;22;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;

Jest tu zapisane wiele wartości i wygląda na to, że pozostałe to wartości historyczne. Mnie interesuje wartość o indeksie 2 (W LUA indeksuje się tablice od 1). Kod będzie wyglądał dużo prościej.

-- http://192.168.0.71/FUNC(4,1,3,0,111)?1609092913525
local response1 = "1;22;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;0;0;21;53;"
local result1 = split(response1, ";")

local set_point = result1[2]
print("set_point: " .. set_point)

W tej chwili wiem jak odczytać podstawowe informacje z rekuperatora. W podobny sposób zidentyfikowałem żądania odpowiadające za zmianę prędkości wiatraka i temperatury nawiewu.
Dla zmiany prędkości wiatraka żądanie HTTP wygląda następująco:

6) GET http://192.168.0.71/FUNC(4,1,6,0,2)?1609092152626 / 192.168.0.71/FUNC(4,1,6,0,1)?1609092152626

W tym wywołaniu ostatni parametr wskazuje na prędkość wiatraka. Czyli kolejne wywołania zmieniające prędkość wiatraka będą wyglądały następująco:

FUNC(4,1,6,0,1), FUNC(4,1,6,0,2), FUNC(4,1,6,0,3), FUNC(4,1,6,0,4)

7a) http://192.168.0.71/FUNC(4,1,6,1,20)?1609112764660

7b) http://192.168.0.71/SETPAR(recent,NaN_23)?1609111602905

Ustawienie nowej temperatury trochę zaskakuje. Zamiast jednego requestu, wysyłane są dwa. O ile pierwszy wydaje się zrozumiały, to ten drugi zaskakuje. Po kilku testach wyszło, że zmiana ‘recent’ pozwala wyświetlić aplikacji webowej jaka była ostatnia temperatura.

Postman

Przed implementacją w Gate Grentona, przygotowałem sobie kolekcję w Postmanie, żeby móc łatwo przetestować czy nic mi nie brakuje. Stworzyłem cztery requesty na podstawie znalezionych wcześniej endpointów.

Za pomocą Postmana można wygenerować polecenie cURL, które następnie można wywołać w konsoli. Poniżej zrzut takiego polecenia dla pierwszego requestu GetData.

curl –location –request GET ‘192.168.0.71/FUNC(4,1,4,0,24)?1609087597747’ \
–header ‘Authorization: Basic YWRtaW46dGFqbmVoYXNsbw==’
‘Authorization Basic’ dlatego, że endpoint do rekuperatora był zabezpieczony w ten dość podstawowy sposób.

Implementacja w OM

Nie lubię robić za dużo “skryptów” w OM, wolę wszystko trzymać w jednym skrypcie (po jednym dla zadanego kontekstu) i za pomocą pierwszego parametru wybieram metodę/akcję, którą skrypt ma wykonać. Napisałem więc jeden skrypt z dwoma parametrami ‘method’ oraz ‘value’, który nazwałem ‘Recuperator’. Dodatkowo stworzyłem obiekt HttpRequest, który nazwałem RecuperatorRequest oraz zmienną CLU, którą nazwałem RECUPERATOR_DATA. W RecuperatorRequest w zdarzeniu OnResponse dodałem wywołanie skryptu: GATE->Recuperator(“response”,0). Sam skrypt wygląda następująco.

print("run recuperator script")
-- fast exit
if (method == 'default' or method == '') then
    print ("fast exit")
    return
end

if (method == 'response') then
    print("response")
    local response = GATE->RecuperatorRequest->ResponseBody
    GATE->RECUPERATOR_DATA = response
    print("rekuperator zwrocil: " .. response);
    return
end

local function doRequest(func)
    local host = 'http://192.168.0.71'
    local path = '/' .. func
    local query = '1234567'
    local method = 'GET'
    local requestType = 1 -- text
    local responseType = 1 -- text

    local headers = {}
    headers['Authorization'] = 'Basic YWRtaW46dGFqbmVoYXNsbw=='

    GATE->RecuperatorRequest->Clear()
    GATE->RecuperatorRequest->SetHost(host)
    GATE->RecuperatorRequest->SetPath(path)
    GATE->RecuperatorRequest->SetQueryStringParams(query)
    GATE->RecuperatorRequest->SetMethod(method)
    GATE->RecuperatorRequest->SetRequestType(requestType)
    GATE->RecuperatorRequest->SetResponseType(responseType)
    GATE->RecuperatorRequest->SetRequestHeaders(headers)
    GATE->RecuperatorRequest->SetTimeout(1)
    GATE->RecuperatorRequest->SendRequest()
end

if (method == 'getData') then
    local func = "FUNC(4,1,4,0,24)"
    doRequest(func)
    return
end

if (method == 'getTemperature') then
    local func = "FUNC(4,1,3,0,111)"
    doRequest(func)
    return
end

if (method == 'setFunSpeed') then
    local func = "FUNC(4,1,6,0,".. value ..")"
    doRequest(func)
    return
end

if (method == 'setTemperature') then
    local func = "FUNC(4,1,6,1," .. value .. ")"
    doRequest(func)
    return
end

Niestety po pierwszych testach okazało się, że moduł Gate nie umie sobie poradzić z response, który zwraca moduł Salda. Problem polega na tym, że po wysłaniu requestu do rekuperatora event OnResponse był wywoływany po 30 sekundach, gdzie normalnie z Postmana czy cURL request trwał jakieś 60ms. Myślę, że znalazłem problem, którego niestety nie jestem w stanie rozwiązać po stronie Gate Http. Wygląda on następująco. Gdy wywołuje żądanie do rekuperatora za pomocą cURL z opcją verbose:

curl -v –location –request GET ‘192.168.0.71/FUNC(4,1,4,0,24)?1609087597747′  –header ‘Authorization: Basic YWRtaW46dGFqbmVoYXNsbw==’
Otrzymuję wynik:
* Trying 192.168.0.71:80…
* TCP_NODELAY set
* Connected to 192.168.0.71 (192.168.0.71) port 80 (#0)
> GET /FUNC(4,1,4,0,24) HTTP/1.1
> Host: 192.168.0.71
> User-Agent: curl/7.65.3
> Accept: */*
> Authorization: Basic YWRtaW46dGFqbmVoYXNsbw==
> Content-Length: 0
> Content-Type: application/x-www-form-urlencoded
>
* Mark bundle as not supporting multiuse
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Content-Type: text/html
< Access-Control-Allow-Origin: *
<
* Closing connection 0
216;0;0;201;0;0;57;0;0;20;0;0;-849;34;1;30;30;0;3;2;338;42;0;0;%
Wydaje mi się, że Gate Http nie rozpoznaje prawidłowo zakończenia zwracanego komunikatu przez Gate Saldy – HTTP 1.0, assume close after body. Po około 20-30 sekundach w Gate Http włączany jest wewnętrzny timeout nad którym nie mam kontroli. Gdy zrobiłem identyczny serwer w Pythonie to działał on idealnie z Gate Http od Grentona. I dla zapytania:
curl -v –location –request GET ‘192.168.0.39:8080/FUNC(4,1,4,0,24)’   –header ‘Authorization: Basic YWRtaW46dGFqbmVoYXNsbw=’  –data-raw ”
Zwracał:
* Trying 192.168.0.39:8080…
* TCP_NODELAY set
* Connected to 192.168.0.39 (192.168.0.39) port 8080 (#0)
> GET /FUNC(4,1,4,0,24) HTTP/1.1
> Host: 192.168.0.39:8080
> User-Agent: curl/7.65.3
> Accept: */*
> Authorization: Basic YWRtaW46YWRtaW4=
> Content-Length: 0
> Content-Type: application/x-www-form-urlencoded
>
* Mark bundle as not supporting multiuse
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/2.7.17
< Date: Sat, 02 Jan 2021 11:31:52 GMT
< Content-Type: text/html
< Access-Control-Allow-Origin: *
<
* Closing connection 0
Ten response już działa dobrze z Gate Http. Żeby obejść problem potrzebuję coś co opakuje request z Gateway Saldy na taki z którym poradzi sobie Gate Http.

PROXY

Zawiedziony tym, że działa to tak strasznie wolno, postanowiłem napisać proxy, które będzie pośredniczyło w komunikacji między Grentonem a Saldą. Mogłem zrobić to za pomocą Nginx, Pythona, JavaScriptu. Ale w ramach rozrywki napisałem proxy w Go. Takie proxy umieściłem na serwerze, którego używam Grafany, HA i innych mądrości.

Moje API do rekuperatora wygląda następująco:

192.168.0.39:18080/recuperator/getData

{“supplyAir”:”22.3″,”exhaustAir”:”6.1″,”extractAir”:”0.0″,”outsideAir”:”0.0″,”humidity”:”33″,”supplyFanSpeed”:”30″,”extractFanSpeed”:”30″,”fanSpeed”:”1″,”temperature”:”22″}

Tu zdecydowałem, że większość danych przygotuję po stronie proxy, dodatkowo opakowałem wynik w JSONa, żeby już nie bawić się po stronie Grentona w parsowanie i wyciąganie danych (co nie do końca jest prawdą). Dodatkowo przeliczyłem prędkość wiatraków z procentów na wartości od 0-4.

192.168.0.39:18080/recuperator/getTemperature

{“temperature”:”22.0″}

192.168.0.39:18080/recuperator/setTemperature?value=22

{“temperature”:”22.0″}

192.168.0.39:18080/recuperator/setFanSpeed?value=1

{“fanSpeed”:”1″}

Hasło do rekuperatora zostało zapisane w konfiguracji proxy, dzięki czemu Grenton nic nie wie o haśle. Ten podstawowy zestaw instrukcji pozwoli na integrację z rekuperatorem.

Kod proxy dostępny tutaj: https://github.com/Domktorymysli/HouseProxy (to raczej POC niż produkcyjne rozwiązanie :D)

Ponowna implementacja w OM

Tym razem dużo się nie zmieniło. Skrypt wygląda następująco.

if (method == 'default' or method == '') then
    print ("wrong method name")
    return
end

function toString(t)
    if (type(t) ~= 'table') then return "error=not_table;" end
    local string = ''
    for i,v in pairs(t) do
        string = string .. i .. '=' .. v .. ';'
    end
    return string
end

local function doRequest(func)
    local host = RECUPERATOR_ADDRESS
    local path = '/' .. func
    local method = 'GET'
    local requestType = 1 -- text
    local responseType = 2 -- json

    GATE->RecuperatorRequest->Clear()
    GATE->RecuperatorRequest->SetHost(host)
    GATE->RecuperatorRequest->SetPath(path)
    GATE->RecuperatorRequest->SetMethod(method)
    GATE->RecuperatorRequest->SetRequestType(requestType)
    GATE->RecuperatorRequest->SetResponseType(responseType)
    GATE->RecuperatorRequest->SetTimeout(1)
    GATE->RecuperatorRequest->SendRequest()
end

if (method == 'init') then
    RECUPERATOR_DATA = {}
end

if (method == 'response') then

    local response = GATE->RecuperatorRequest->ResponseBody

    if response.humidity ~= nil then
        RECUPERATOR_DATA = response
    end

    if response.temperature ~= nil then
        RECUPERATOR_DATA['temperature'] = response.temperature
    end

    if response.fanSpeed ~= nil then
        RECUPERATOR_DATA['fanSpeed'] = response.fanSpeed
    end

    RECUPERATOR_DATA_STR = toString(RECUPERATOR_DATA)

    return
end

if (method == 'getData') then
    doRequest("recuperator/getData")
    return
end

if (method == 'getTemperature') then
    doRequest("recuperator/getTemperature")
    return
end

if (method == 'setFanSpeed') then
    doRequest("recuperator/setFanSpeed?value=" .. value)
    return
end

if (method == 'setTemperature') then
    doRequest("recuperator/setTemperature?value=" .. value)
    return
end

Po wykonaniu skryptu Recuperator(‘getData’) w zmiennych CLU:

RECUPERATOR_DATA – pojawi się tablica Lua z danymi z endpointu
RECUPERATOR_DATA_STR – pojawią się dane, które będę przekazywał między CLU. Jest to taka mega prosta “serializacja”, bo nie chcę rozbijać tych danych na wiele pól.

Kod potrzebny do zapisania takiej “płaskiej” tablicy stringa wygląda tak:

function toString(t)
    if (type(t) ~= 'table') then return "error=not_table;" end
    local string = ''
    for i,v in pairs(t) do
        string = string .. i .. '=' .. v .. ';'
    end
    return string
end

Natomiast w drugą stronę będzie wyglądał tak:

function toArray(str)
    local delimiter = ';'
    local result = {};
    for match in (str..delimiter):gmatch("(.-)"..delimiter) do
        local tmp = {}
        for m in match:gmatch("([^=]*)") do
            table.insert(tmp, m)
        end
        result[tmp[1]] = tmp[2]
    end
    return result;
end

Nie jest to idealne rozwiązanie, bo Grenton pozwala wysyłać dość krótkie wiadomości między CLU (wygląda to jak proste wywołania RPC) i ta “serializacja” nie jest odporna znaki “;” oraz “=”, ale chciałem pokazać inne podejście. Próbowałem uruchomić biblioteki parsujące JSON, ale z racji braku czasu sobie odpuściłem.

Prosty przykład, który pokaże jak to działa. Dla danych w tablicy:

local a = {
    supplyAir = 22.0,
    exhaustAir = 6.1,
    extractAir = 0.0,
    outsideAir = 0.0,
    humidity = 33,
    supplyFanSpeed = 30,
    extractFanSpeed = 30,
    fanSpeed = 1,
    temperature = 22.0
}

Otrzymamy string:

humidity=33;supplyFanSpeed=30;extractAir=0.0;supplyAir=22.0;fanSpeed=1;outsideAir=0.0;extractFanSpeed=30;exhaustAir=6.1;temperature=22.0;

Teraz na CLU ze SmartPanelem mogę pobrać dane z rekuperatora w następujący sposób.

DOM->RECUPERATOR_DATA = GATE->RECUPERATOR_DATA_STR

Po czym na CLU “DOM”  pojawią się dane, które za pomocą funkcji toArray(str) znów zamienię na tablicę.

Jeżeli ktoś by się zastanawiał czemu z CLU “GATE” nie można wysłać danych bezpośrednio na CLU “DOM”, to ma to związek z architekturą systemu. Temat był już nie raz poruszany na Facebookowej grupie Grentona. Kiedyś opiszę architekturę dokładniej.

Podsumowanie

Z dość prostego przykładu zrobił się całkiem długi post. Prawdopodobnie, gdyby Salda i Grenton lepiej ze sobą współpracowały, to bym uniknął zabawy z pisaniem proxy. Z drugiej strony, może komuś kiedyś się przytrafi podobny błąd.

 

Please follow and like us: