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:
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==’
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:
* 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;%
* 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
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.