Ik heb thuis een AUX Freedom airco hangen. Prima ding, alleen ik wilde hem natuurlijk ook gewoon vanuit Homey kunnen bedienen. De airco heeft wifi en wordt bestuurd met de app AC Freedom, dus ik dacht eigenlijk dat dit niet heel ingewikkeld zou moeten zijn.
Dat viel behoorlijk tegen....
Er is geen officiële Homey-app voor deze airco. Op het Homey-forum staat zelfs al sinds 2022 een topic van iemand die precies hetzelfde wilde. Af en toe komt daar weer iemand bij met de vraag of er al iets nieuws is, maar echt veel verder lijkt het nooit te zijn gekomen.
Dus toen dacht ik: dan zoek ik het zelf wel uit.
Ik had vooraf eigenlijk geen idee hoe ver ik zou komen. Mijn doel was in eerste instantie gewoon om te kijken of ik de API van de app kon begrijpen. Uiteindelijk ben ik een paar avonden bezig geweest met netwerkverkeer, encryptie, een Android-APK uitpluizen en een hoop hexadecimale data waar ik op een gegeven moment ook wel een beetje klaar mee was.
Dit is hoe het is gegaan.
Eerst maar eens kijken wat het ding gebruikt
Mijn eerste gedachte was Tuya. Bij goedkope wifi-apparaten is dat meestal een redelijke gok en Homey kan daar al mee overweg.
Ik probeerde de airco via Tuya Smart Life toe te voegen, maar daar gebeurde helemaal niks.
Daarna Broadlink geprobeerd. Dat leek ook niet zo gek, omdat er behoorlijk wat AUX-airco’s zijn waarbij Broadlink-achtige hardware wordt gebruikt. Er bestaan ook al verschillende libraries waarmee mensen dat protocol hebben uitgezocht.
Ook geen succes. Een UDP-handshake naar het IP-adres van de wifi-module liep gewoon in een timeout.
Dan maar kijken wat de officiële app zelf doet.
Even meekijken met de app
Ik heb HTTP Toolkit gebruikt om het verkeer van mijn iPhone te onderscheppen. De telefoon via de proxy laten lopen is niet zo spannend, maar op iOS moet je daarna nog wel het certificaat van de proxy daadwerkelijk vertrouwen. Dat zit onder Instellingen → Algemeen → Info → Certificaatvertrouwensinstellingen.
Alleen het profiel installeren is dus niet genoeg. Dat had ik natuurlijk eerst wel gedaan, waardoor ik een tijdje dacht dat de app misschien certificate pinning gebruikte.
Toen het eenmaal werkte, werd het een stuk interessanter. De app praat met eu-smthome-api.aux-global.com — dat is dus gewoon een server van AUX zelf. Geen Tuya of andere partij ertussen.
Ik zag onder andere een login-endpoint, een endpoint om apparaten op te halen en een endpoint waarmee opdrachten naar de airco gestuurd worden. Dat was eigenlijk al genoeg om te weten dat er waarschijnlijk wel iets van te maken moest zijn.
Alleen de login had nog een klein probleem.
De login uitzoeken
De login-request zag er ongeveer zo uit:
{
"password": "<172 base64-tekens>",
"account": "<44 base64-tekens>",
"ts": "1786542139800",
"publicKeyBase64": "<de RSA-sleutel>"
}Het wachtwoord was gelukkig vrij snel duidelijk. 172 base64-tekens komt neer op 128 bytes. Dat past precies bij RSA-1024. De app haalt vlak voor het inloggen een publieke sleutel op en gebruikt die om het wachtwoord te versleutelen. Dat bleek inderdaad RSA/ECB/PKCS1Padding te zijn. Mooi, één ding opgelost.
Het e-mailadres was vervelender. Dat veld decodeerde naar 32 bytes. Dat kan dus geen RSA-1024 zijn, want dan zou de ciphertext 128 bytes zijn. 32 bytes zijn precies twee AES-blokken, dus ik ging ervan uit dat hier ergens AES gebruikt werd. Alleen: waar kwam de sleutel vandaan?
Ik heb dezelfde login meerdere keren onderschept. Iedere keer werd er een andere RSA-sleutel gebruikt, maar het versleutelde e-mailadres was iedere keer exact hetzelfde. Dat betekende eigenlijk dat de RSA-sleutel hier helemaal niks mee te maken had.
Dus begon het gokken. Ik heb allerlei dingen geprobeerd: hashes van de publieke sleutel, de ruwe sleutel, timestamps, tokens die de app meestuurde en verschillende combinaties van AES en SM4. Zowel CBC als ECB, verschillende padding-opties enzovoort.
Uiteindelijk zat ik op 360 pogingen. Geen enkele werkte.
| Poging | Cipher | Resultaat |
|---|---|---|
| Directe RSA | PKCS1v1.5, OAEP | FAIL — code 9023 |
| Verschillende afgeleide sleutels | AES-128/256, SM4, CBC/ECB | 168 pogingen — FAIL |
| Extra waarden uit Bugly-headers | dezelfde combinaties | 360 pogingen — FAIL |
Op een gegeven moment was het wel duidelijk dat ik waarschijnlijk op de verkeerde plek aan het zoeken was. Als die sleutel niet uit het netwerkverkeer kwam, moest hij ergens in de app zelf zitten.
Dan maar de Android-app bekijken
Ik gebruik zelf een iPhone, maar AUX heeft ook een Android-app. Die heet AUX Home in plaats van AC Freedom. Dat kwam eigenlijk wel goed uit: Android-apps zijn een stuk makkelijker uit elkaar te trekken dan een iOS-app.
Ik heb de .xapk gedownload, uitgepakt en met jadx de APK gedecompileerd. Dat ging verrassend makkelijk. 6.337 classes kwamen eruit, op 18 na zonder noemenswaardige fouten.
Eerst even gezocht op aux-global om te controleren of ik wel naar dezelfde app/backend zat te kijken. Dat klopte. Daarna gewoon zoeken op dingen als encrypt, AES en password.
En toen vond ik een class die letterlijk EncryptUtil heette, met een functie genaamd encryptPassWord. Die verwees weer naar een ander klein klasje. Daar stond uiteindelijk dit:
public static String e(String str) {
SecretKeySpec key = new SecretKeySpec(
"<weggelaten>".getBytes("UTF-8"), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(1, key);
return base64(cipher.doFinal(str.getBytes("UTF-8")));
}Daar stond de sleutel gewoon: een hardcoded AES-128-sleutel in de Android-app.
Ik heb de sleutel hier bewust weggelaten. Hij staat namelijk in de officiële APK en lijkt niet uniek per gebruiker of installatie.
Het NCSC adviseert bij ontdekte kwetsbaarheden om eerst responsible disclosure toe te passen: de leverancier informeren, voldoende informatie geven om het probleem te reproduceren, en de details niet direct publiek maken.
Ik heb die gebruikt om dezelfde ciphertext te decrypten waar ik eerder al die 360 pogingen op had gedaan. En daar kwam gewoon mijn e-mailadres uit:
> voorbeeld@mail.nl (niet mijn echte mail)Dat was wel zo’n moment waarop je denkt: had ik dit niet gewoon meteen kunnen vinden? Uiteindelijk had ik dus een halve avond allerlei combinaties zitten proberen terwijl de sleutel letterlijk in de APK stond.
De API verder uitzoeken
Toen de login eenmaal werkte, ging het eigenlijk vrij snel. Ik kon mijn apparaten ophalen en zag een control-endpoint waarmee ik opdrachten naar de airco kon sturen. Zoiets als:
{
"intent": {
"on_off": 1,
"temperature": 22
},
"dst": 1,
"deviceId": "..."
}Er was ook een configuratie-endpoint waar eigenlijk alle mogelijke instellingen van de airco in stonden. Ventilatorstanden, modi, zwenken, timers enzovoort. Dat was mooi meegenomen, want daarmee hoefde ik niet alles zelf uit te zoeken.
Vanaf daar was het vooral Homey bouwen. Ik heb een nieuwe Homey-app aangemaakt, de API-client naar TypeScript omgezet en daar een driver omheen gebouwd. Voor de airco gebruik ik de standaard airconditioning device class van Homey.
De ventilator was wat lastiger. Homey gaat bij fan_speed uit van een waarde van 0 tot 100%, terwijl deze airco acht verschillende standen heeft. Daar heb ik dus een custom capability voor gemaakt.
Voor het inloggen kon ik gewoon de standaard login_credentials-view van Homey gebruiken. Geen reden om daar zelf een hele interface voor te bouwen.
Op dat moment kon ik de airco vanuit Homey aanzetten, uitzetten, de temperatuur veranderen enzovoort. Maar toen kwam het volgende probleem.
De status uitlezen
Besturen is één ding. Weten wat de airco op dat moment doet is iets anders. In de response van de API zat een statusblok:
"status": {
"running": "bb000700000018000121e4...",
"control": "bb00070000000f00011147...",
"type": "uart"
}Dat zag er interessant uit. De hex begon met bb00, wat behoorlijk veel leek op het oude Broadlink-formaat. De communicatie liep nu wel via de cloud, maar misschien gebruikte AUX voor de daadwerkelijke airco-commando’s nog steeds hetzelfde soort formaat.
Dus weer wat gaan testen. Ik veranderde steeds één instelling en keek welke bytes veranderden. Na een tijdje kwamen een paar dingen redelijk duidelijk naar voren:
| Byte | Veld | Formule |
|---|---|---|
| 10 | Temperatuur | (byte >> 3) + 8 |
| 12 | Halve graad | byte & 0x80 |
| 15 | Modus | byte >> 5 |
| 18 | Aan/uit | (byte >> 5) & 1 |
Die heb ik vervolgens gecontroleerd door daadwerkelijk een opdracht naar de airco te sturen en daarna de status opnieuw op te halen.
Daarbij kwam ik nog iets tegen wat ik niet helemaal had verwacht. Als ik meerdere instellingen tegelijk stuurde terwijl de airco uit stond, werden sommige daarvan gewoon genegeerd. Eén instelling per request werkte wel iedere keer. Prima. Dan doen we dat gewoon zo.
En toen dacht ik dat ik de temperatuur had gevonden
Het volgende wat ik wilde hebben was de kamertemperatuur. Ik had twee metingen gedaan. Eén keer gaf de airco 17 graden aan en een andere keer 24 graden. In beide gevallen leek dezelfde formule te werken op een paar bytes in het running-veld: byte − 23.
Mooi dacht ik. Gevonden. Dus in de code gezet.
Tot ik een derde meting deed. 18 graden zou volgens mijn formule een waarde van 41 moeten geven. Ik kreeg 56.
Even terugkijken wat er veranderd was. De ventilator stond deze keer op hoog in plaats van auto. En toen viel het kwartje: waarschijnlijk keek ik helemaal niet naar de kamertemperatuur.
Waarschijnlijk waren het bytes voor een andere temperatuur, bijvoorbeeld van de spoel of de uitblaaslucht. Als de ventilator rustig draait kan zo’n waarde toevallig dicht bij de kamertemperatuur liggen. Zodra de ventilator harder draait, zie je het verschil. Dus twee metingen hadden me eigenlijk op het verkeerde been gezet.
Ik heb de functie weer uit de app gehaald.
Tweede poging: de temperatuur toch gevonden
Een tijd later ben ik er nog een keer voor gaan zitten, maar dan wel anders. In plaats van af en toe met de hand een meting te doen, heb ik een scriptje gebouwd dat op de achtergrond bleef draaien en zelf continu de status opvroeg.
Het probleem was namelijk dat dat running-veld helemaal niet steeds meteen ververst. Soms gebeurde dat na een paar minuten, soms pas na een half uur. Handmatig blijven controleren was dus vooral wachten.
Het script bleef daarom stil op de achtergrond pollen en piepte pas zodra de hex daadwerkelijk veranderd was. Op dat moment vroeg het mij om de temperatuur die ik op dat exacte moment op het scherm van de airco zag. Zo kreeg elke meting automatisch een betrouwbaar tijdstip en een echte, verse aflezing — in plaats van een slag om de arm.
Na een paar uur had ik acht van dat soort momenten verzameld, over verschillende standen (koelen, verwarmen, uit) en ventilatorsnelheden. En toen zag ik iets moois: byte 15 van het running-veld, min 32, kwam bij zeven van de acht metingen exact overeen met de temperatuur die ik had ingevoerd.
kamertemperatuur = byte[15] − 32
Bevestigd met een schone, geïsoleerde meting waarbij écht alleen de kamertemperatuur veranderde (24°C → 20°C, verder exact dezelfde modus/fan/swing/target) — de byte daalde daarbij precies 4 punten mee.
De ene meting die niet klopte was de allereerste van die sessie. Waarschijnlijk was dat gewoon een moment waarop de waarde die ik aflas nog niet was bijgewerkt — hetzelfde soort vertraging dat de officiële app zelf ook laat zien.
Deze keer voelde het echt overtuigend, in tegenstelling tot de vorige poging: de formule bleef namelijk ook kloppen toen ik expres van modus en ventilatorsnelheid wisselde — precies de omstandigheden waarop de eerste “oplossing” stukliep.
Wat ik er uiteindelijk aan had
Het belangrijkste wat ik hiervan heb geleerd is eigenlijk dat je soms gewoon van aanpak moet veranderen.
Eerst heb ik geprobeerd het protocol via het netwerk te begrijpen. Dat werkte prima voor de API, maar niet voor de sleutel. Die stond uiteindelijk gewoon in de Android-app.
Ook was het achteraf gezien heel handig dat AUX twee apps heeft. Android was in dit geval veel makkelijker om te onderzoeken dan de iOS-app.
En vooral: twee metingen zijn geen bewijs. Dat laatste ging bij de kamertemperatuur bijna mis. De formule klopte twee keer perfect, dus ik dacht dat ik hem gevonden had. Pas bij de derde meting bleek dat ik eigenlijk naar iets anders keek.
Toen ik het later nog een keer probeerde, heb ik het dan ook anders aangepakt: niet een paar keer met de hand meten en hopen dat het klopt, maar een script laten wachten tot er echt iets veranderde en dat automatisch laten vastleggen. Saai werk automatiseren bleek uiteindelijk net zo belangrijk als het uitzoeken van het protocol zelf.
Hoe ver ik nu ben
De Homey-app werkt inmiddels volledig op mijn eigen airco: inloggen, aan/uit, modus, doeltemperatuur, ventilatorsnelheid én de gemeten kamertemperatuur worden allemaal correct uitgelezen en aangestuurd. Zwenkstand kan ik wel instellen, maar verticaal en horizontaal apart uitlezen lukt nog niet betrouwbaar.
Ik heb de app ook klaargemaakt voor de eisen van de Homey App Store: een eigen icoon in plaats van de standaard raket, een echte foto van mijn airco als driver-afbeelding, en een Engelstalige beschrijving en readme die aan de richtlijnen voldoen.
De broncode staat inmiddels open op GitHub, en ik heb de app ook echt gepubliceerd via homey app publish. Op het moment van schrijven staat hij nog niet publiek in de Community Store — die aanmelding is de volgende stap — maar de build is al gewoon te downloaden en te installeren.
Best een hoop werk voor iets waarvan ik in eerste instantie dacht: die airco heeft wifi, hoe moeilijk kan het zijn?
