/** * Hubitat Device Driver * Winix Account * v1.0.0 * https://github.com/sethkinast/hubitat-winix/ * * MIT License * * Copyright (c) 2026 Seth Kinast * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import groovy.transform.Field import groovy.json.JsonSlurper import groovy.json.JsonOutput import java.security.MessageDigest import javax.crypto.Cipher import javax.crypto.Mac import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @Field static final String COGNITO_REGION = 'us-east-1' @Field static final String COGNITO_USER_POOL_ID = 'us-east-1_Ofd50EosD' @Field static final String COGNITO_APP_CLIENT_ID = '5rjk59c5tt7k9g8gpj0vd2qfg9' @Field static final String COGNITO_IDENTITY_POOL_ID = 'us-east-1:84008e15-d6af-4698-8646-66d05c1abe8b' @Field static final String COGNITO_IDP_ENDPOINT = 'https://cognito-idp.us-east-1.amazonaws.com/' @Field static final String COGNITO_IDENTITY_ENDPOINT = 'https://cognito-identity.us-east-1.amazonaws.com/' @Field static final String WINIX_GET_DEVICES_URL = 'https://us.mobile.winix-iot.com/getDeviceInfoList' @Field static final String WINIX_REGISTER_USER_URL = 'https://us.mobile.winix-iot.com/registerUser' @Field static final String WINIX_INIT_URL = 'https://us.mobile.winix-iot.com/init' @Field static final String WINIX_CHECK_ACCESS_TOKEN_URL = 'https://us.mobile.winix-iot.com/checkAccessToken' @Field static final String AES_KEY_HEX = '84be38f854e320dd4a0a8c7fe0f3a9b84c288445916933fc222465bbd5a518d0' @Field static final String AES_IV_HEX = 'dfd55f316e72e97b905f8739005c99a7' @Field static final String MOBILE_APP_VERSION = '1.5.7' @Field static final String MOBILE_MODEL = 'SM-G988B' @Field static final String N_HEX = 'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1' + '29024E088A67CC74020BBEA63B139B22514A08798E3404DD' + 'EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245' + 'E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED' + 'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D' + 'C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F' + '83655D23DCA3AD961C62F356208552BB9ED529077096966D' + '670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B' + 'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9' + 'DE2BCBF6955817183995497CEA956AE515D2261898FA0510' + '15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64' + 'ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7' + 'ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B' + 'F12FFA06D98A0864D87602733EC86A64521F2B18177B200C' + 'BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31' + '43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF' @Field static final String G_HEX = '2' @Field static final String CHILD_NAMESPACE = 'cogdev' @Field static final String CHILD_TYPE = 'Winix Purifier' @Field static final Long TOKEN_EXPIRY_BUFFER_MS = 10L * 60L * 1000L // 10 minutes @Field static final List LOGIN_STATUSES = ['LOGGED_OUT', 'OK', 'BAD_CREDENTIALS', 'REFRESH_TOKEN_INVALID', 'ERROR'] metadata { definition( name: 'Winix Account', namespace: 'cogdev', author: 'Seth Kinast ', importUrl: 'https://raw.githubusercontent.com/sethkinast/hubitat-winix/master/winix-account.groovy' ) { capability 'Actuator' capability 'Refresh' capability 'Initialize' attribute 'loginStatus', 'enum', LOGIN_STATUSES } } preferences { section('Winix Account') { input name: 'username', type: 'string', title: 'Winix account email', required: true input name: 'password', type: 'password', title: 'Winix account password', required: true } section('Advanced') { input name: 'debugLoggingEnabled', type: 'bool', title: 'Enable debug logging', defaultValue: false } } /* Lifecycle */ void updated() { initialize() if (debugLoggingEnabled) { runIn(1800, 'disableDebugLogging') } } void initialize() { unschedule() runEvery30Minutes('refreshSession') if (username && password) { runIn(1, 'login') } } void refresh() { if (device.currentValue('loginStatus') == 'OK' && state.accessToken) { ensureFreshSession() discoverDevices() } else if (username && password) { login() } else { log.error 'Set your Winix account email and password in device preferences first.' } } /* Session */ void login() { if (!username || !password) { log.error 'Set your Winix account email and password in device preferences first.' sendEvent(name: 'loginStatus', value: 'BAD_CREDENTIALS') return } String status try { status = performLogin(username, password) } catch (Exception e) { log.error "Winix login failed: ${e.message}" status = 'ERROR' } sendEvent(name: 'loginStatus', value: status) if (status == 'OK') { log.info 'Winix login successful.' discoverDevices() } } void refreshSession() { if (!state.refreshToken) { logDebug 'No stored refresh token yet; run refresh() first.' return } try { Map resp = cognitoPost(COGNITO_IDP_ENDPOINT, 'AWSCognitoIdentityProviderService.InitiateAuth', [ AuthFlow : 'REFRESH_TOKEN', ClientId : COGNITO_APP_CLIENT_ID, AuthParameters: [REFRESH_TOKEN: state.refreshToken] ]) Map authResult = resp?.AuthenticationResult as Map if (!authResult?.AccessToken) { String type = resp?.__type as String if (type && type.contains('NotAuthorizedException')) { log.warn 'Winix refresh token expired or invalid; attempting a full re-login.' sendEvent(name: 'loginStatus', value: 'REFRESH_TOKEN_INVALID') if (username && password) { login() } } else { log.error "Winix token refresh failed: ${resp}" sendEvent(name: 'loginStatus', value: 'ERROR') } return } state.accessToken = authResult.AccessToken state.idToken = authResult.IdToken state.expiresAt = now() + ((authResult.ExpiresIn as Long) * 1000L) establishSession() sendEvent(name: 'loginStatus', value: 'OK') logDebug 'Session refreshed.' } catch (Exception e) { log.error "Winix token refresh error: ${e.message}" sendEvent(name: 'loginStatus', value: 'ERROR') } } void discoverDevices() { if (!state.accessToken) { log.error 'Not logged in; run refresh() first.' return } ensureFreshSession() Map resp = mobilePost(WINIX_GET_DEVICES_URL, [accessToken: state.accessToken, uuid: state.uuid]) List devices = resp?.deviceInfoList as List if (!devices) { log.warn 'No Winix devices found on this account (or the request failed - enable debug logging for details).' return } devices.each { d -> upsertChildDevice(d as Map) } } String getIdentityId() { ensureFreshSession() return state.identityId } /* Device discovery */ private void upsertChildDevice(Map d) { String deviceId = d.deviceId as String String dni = "winix-${deviceId}" def child = getChildDevice(dni) if (!child) { child = addChildDevice(CHILD_NAMESPACE, CHILD_TYPE, dni, [name: 'Winix Purifier', label: (d.deviceAlias ?: 'Winix Purifier') as String, isComponent: false]) log.info "Created child device for '${d.deviceAlias}' (${deviceId})." } child.updateDataValue('winixDeviceId', deviceId) child.updateDataValue('winixMac', (d.mac ?: '') as String) child.updateDataValue('winixModel', (d.modelName ?: d.modelId ?: '') as String) child.updateDataValue('winixFilterMaxPeriodMonths', (d.modelFilterMaxPeriod ?: d.filterMaxPeriod ?: '9') as String) child.refreshDeviceInfo() } /* Session bootstrap */ private void ensureFreshSession() { Long expiresAt = state.expiresAt as Long if (expiresAt && now() < (expiresAt - TOKEN_EXPIRY_BUFFER_MS)) { return } refreshSession() } private String performLogin(String username, String password) { BigInteger bigN = new BigInteger(N_HEX, 16) BigInteger g = new BigInteger(G_HEX, 16) BigInteger k = new BigInteger(hexHash('00' + N_HEX + '0' + G_HEX), 16) byte[] randomBytes = secureRandomBytes(128) BigInteger smallA = new BigInteger(1, randomBytes).mod(bigN) BigInteger largeA = g.modPow(smallA, bigN) if (largeA.mod(bigN) == BigInteger.ZERO) { throw new Exception("Safety check for SRP 'A' value failed") } Map initResp = cognitoPost(COGNITO_IDP_ENDPOINT, 'AWSCognitoIdentityProviderService.InitiateAuth', [ AuthFlow : 'USER_SRP_AUTH', ClientId : COGNITO_APP_CLIENT_ID, AuthParameters: [USERNAME: username, SRP_A: largeA.toString(16)] ]) if (initResp?.ChallengeName != 'PASSWORD_VERIFIER') { String type = initResp?.__type as String if (type && (type.contains('UserNotFoundException') || type.contains('NotAuthorizedException'))) { log.error 'Winix login failed: incorrect email or password.' return 'BAD_CREDENTIALS' } log.error "Unexpected Cognito response to InitiateAuth: ${initResp}" return 'ERROR' } Map cp = initResp.ChallengeParameters as Map String userIdForSrp = cp.USER_ID_FOR_SRP as String BigInteger salt = new BigInteger(cp.SALT as String, 16) BigInteger srpB = new BigInteger(cp.SRP_B as String, 16) String secretBlockB64 = cp.SECRET_BLOCK as String String poolIdSuffix = COGNITO_USER_POOL_ID.split('_')[1] BigInteger u = new BigInteger(hexHash(padHex(largeA) + padHex(srpB)), 16) if (u == BigInteger.ZERO) { throw new Exception("SRP 'U' value was zero") } String usernamePassword = "${poolIdSuffix}${userIdForSrp}:${password}" String usernamePasswordHash = hashSha256Hex(usernamePassword.getBytes('UTF-8')) BigInteger x = new BigInteger(hexHash(padHex(salt) + usernamePasswordHash), 16) BigInteger gModPowXN = g.modPow(x, bigN) BigInteger base = (srpB.subtract(k.multiply(gModPowXN))).mod(bigN) BigInteger exponent = smallA.add(u.multiply(x)) BigInteger sValue = base.modPow(exponent, bigN) byte[] ikm = hexToBytes(padHex(sValue)) byte[] saltForHkdf = hexToBytes(padHex(u)) byte[] hkdfKey = computeHkdf(ikm, saltForHkdf) String timestamp = formattedTimestamp() byte[] secretBlockBytes = secretBlockB64.decodeBase64() ByteArrayOutputStream messageBuf = new ByteArrayOutputStream() messageBuf.write(poolIdSuffix.getBytes('UTF-8')) messageBuf.write(userIdForSrp.getBytes('UTF-8')) messageBuf.write(secretBlockBytes) messageBuf.write(timestamp.getBytes('UTF-8')) byte[] signature = hmacSha256(hkdfKey, messageBuf.toByteArray()) String signatureB64 = signature.encodeBase64().toString() Map challengeResp = cognitoPost(COGNITO_IDP_ENDPOINT, 'AWSCognitoIdentityProviderService.RespondToAuthChallenge', [ ClientId : COGNITO_APP_CLIENT_ID, ChallengeName : 'PASSWORD_VERIFIER', ChallengeResponses: [ USERNAME : userIdForSrp, PASSWORD_CLAIM_SECRET_BLOCK: secretBlockB64, TIMESTAMP : timestamp, PASSWORD_CLAIM_SIGNATURE : signatureB64 ] ]) if (challengeResp?.ChallengeName == 'NEW_PASSWORD_REQUIRED') { log.error 'Winix account requires a new password - log into the Winix mobile app first, then retry.' return 'ERROR' } Map authResult = challengeResp?.AuthenticationResult as Map if (!authResult?.AccessToken) { String type = challengeResp?.__type as String if (type && type.contains('NotAuthorizedException')) { log.error 'Winix login failed: incorrect email or password.' return 'BAD_CREDENTIALS' } log.error "Cognito did not return an AuthenticationResult: ${challengeResp}" return 'ERROR' } state.accessToken = authResult.AccessToken state.idToken = authResult.IdToken state.refreshToken = authResult.RefreshToken state.expiresAt = now() + ((authResult.ExpiresIn as Long) * 1000L) state.userId = decodeJwt(state.accessToken as String).sub establishSession() return 'OK' } private void establishSession() { state.uuid = generateUuid(state.accessToken as String) state.identityId = resolveIdentityId(state.idToken as String) registerUser() initMobileSession() checkAccessToken() } private String resolveIdentityId(String idToken) { Map resp = cognitoPost(COGNITO_IDENTITY_ENDPOINT, 'AWSCognitoIdentityService.GetId', [ IdentityPoolId: COGNITO_IDENTITY_POOL_ID, Logins : [(('cognito-idp.' + COGNITO_REGION + '.amazonaws.com/' + COGNITO_USER_POOL_ID)): idToken] ]) if (!resp?.IdentityId) { throw new Exception("Cognito GetId returned no IdentityId: ${resp}") } return resp.IdentityId as String } private void registerUser() { mobilePost(WINIX_REGISTER_USER_URL, [ identityId : state.identityId, accessToken: state.accessToken, uuid : state.uuid, email : username, osType : 'android', osVersion : '29', mobileLang : 'en', appVersion : MOBILE_APP_VERSION, mobileModel: MOBILE_MODEL ]) } private void initMobileSession() { mobilePost(WINIX_INIT_URL, [ accessToken: state.accessToken, uuid : state.uuid, region : 'US' ]) } private void checkAccessToken() { mobilePost(WINIX_CHECK_ACCESS_TOKEN_URL, [ identityId : state.identityId, accessToken: state.accessToken, uuid : state.uuid, osVersion : '29', mobileLang : 'en', appVersion : MOBILE_APP_VERSION, mobileModel: MOBILE_MODEL ]) } /* SRP */ private byte[] secureRandomBytes(int numBytes) { ByteArrayOutputStream buf = new ByteArrayOutputStream() while (buf.size() < numBytes) { UUID uuid = UUID.randomUUID() long msb = uuid.getMostSignificantBits() long lsb = uuid.getLeastSignificantBits() [msb, lsb].each { long word -> for (int shift = 56; shift >= 0; shift -= 8) { buf.write((int) ((word >>> shift) & 0xFF)) } } } return buf.toByteArray()[0..= 0) { h = '00' + h } return h } private String hexHash(String hexString) { return hashSha256Hex(hexToBytes(hexString)) } private String hashSha256Hex(byte[] data) { MessageDigest md = MessageDigest.getInstance('SHA-256') return bytesToHex(md.digest(data)) } private byte[] computeHkdf(byte[] ikm, byte[] salt) { byte[] prk = hmacSha256(salt, ikm) byte[] infoBits = 'Caldera Derived Key'.getBytes('UTF-8') ByteArrayOutputStream infoBitsBuf = new ByteArrayOutputStream() infoBitsBuf.write(infoBits) infoBitsBuf.write(0x01) byte[] full = hmacSha256(prk, infoBitsBuf.toByteArray()) return full[0..<16] as byte[] } private byte[] hmacSha256(byte[] key, byte[] message) { Mac mac = Mac.getInstance('HmacSHA256') mac.init(new SecretKeySpec(key, 'HmacSHA256')) return mac.doFinal(message) } private String formattedTimestamp() { List weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] List months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] Calendar cal = Calendar.getInstance(TimeZone.getTimeZone('UTC')) int dow = cal.get(Calendar.DAY_OF_WEEK) - 1 int month = cal.get(Calendar.MONTH) int day = cal.get(Calendar.DAY_OF_MONTH) int hh = cal.get(Calendar.HOUR_OF_DAY) int mm = cal.get(Calendar.MINUTE) int ss = cal.get(Calendar.SECOND) int yyyy = cal.get(Calendar.YEAR) return "${weekdays[dow]} ${months[month]} ${day} ${pad2(hh)}:${pad2(mm)}:${pad2(ss)} UTC ${yyyy}" } private String pad2(int n) { return n < 10 ? "0${n}" : "${n}" } /* UUID / JWT */ private String generateUuid(String accessToken) { String sub = decodeJwt(accessToken).sub as String long p1 = crc32Of('github.com/hfern/winixctl' + sub) long p2 = crc32Of('HGF' + sub) return String.format('%08x%08x', p1, p2) } private long crc32Of(String s) { byte[] data = s.getBytes('UTF-8') long crc = 0xFFFFFFFFL data.each { b -> crc ^= (b & 0xFF) 8.times { crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xEDB88320L : crc >>> 1 } } return (crc ^ 0xFFFFFFFFL) & 0xFFFFFFFFL } private Map decodeJwt(String token) { String payload = token.split('\\.')[1] String normalized = payload.replace('-', '+').replace('_', '/') while (normalized.length() % 4 != 0) { normalized += '=' } byte[] decoded = normalized.decodeBase64() return new JsonSlurper().parseText(new String(decoded, 'UTF-8')) as Map } /* AES (Winix "mobile" API) */ private byte[] winixAesEncrypt(String jsonPayload) { Cipher cipher = Cipher.getInstance('AES/CBC/PKCS5Padding') cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(hexToBytes(AES_KEY_HEX), 'AES'), new IvParameterSpec(hexToBytes(AES_IV_HEX))) return cipher.doFinal(jsonPayload.getBytes('UTF-8')) } private String winixAesDecrypt(byte[] cipherBytes) { Cipher cipher = Cipher.getInstance('AES/CBC/PKCS5Padding') cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(hexToBytes(AES_KEY_HEX), 'AES'), new IvParameterSpec(hexToBytes(AES_IV_HEX))) byte[] plain = cipher.doFinal(cipherBytes) return new String(plain, 'UTF-8') } private byte[] hexToBytes(String hex) { int len = hex.length() byte[] out = new byte[(int) (len / 2)] for (int i = 0; i < len; i += 2) { out[(int) (i / 2)] = (byte) Integer.parseInt(hex.substring(i, i + 2), 16) } return out } private String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder() bytes.each { b -> sb.append(String.format('%02x', b)) } return sb.toString() } private Map mobilePost(String url, Map payload) { String jsonPayload = JsonOutput.toJson(payload) byte[] cipherBytes = winixAesEncrypt(jsonPayload) logDebug "POST ${url} - plaintext payload: ${jsonPayload}" logDebug "POST ${url} - ciphertext length: ${cipherBytes.length} bytes" Map params = [ uri : url, headers : ['Accept': 'application/octet-stream'], requestContentType: 'application/octet-stream', contentType : 'application/octet-stream', body : cipherBytes, timeout : 20 ] Map result = [:] try { httpPost(params) { resp -> String text = rawDataToText(resp.data) logDebug "POST ${url} - HTTP ${resp.status}, raw data: ${text}" result = decryptMobileResponseText(text, url) } } catch (groovyx.net.http.HttpResponseException e) { String text = rawDataToText(e.response?.data) logDebug "POST ${url} - HTTP ${e.statusCode}, raw data: ${text}" Map decrypted = decryptMobileResponseText(text, url) result = decrypted ?: [:] result.httpError = true result.httpStatus = e.statusCode if (decrypted) { log.warn "POST ${url} failed (HTTP ${e.statusCode}): ${decrypted}" } else { log.warn "POST ${url} failed (HTTP ${e.statusCode}) and the error body could not be decrypted - see debug log for the raw body." } } return result } private String rawDataToText(rawData) { if (rawData instanceof String) { return rawData } else if (rawData instanceof byte[]) { return new String((byte[]) rawData, 'ISO-8859-1') } else if (rawData != null) { try { return rawData.getText('ISO-8859-1') } catch (MissingMethodException ignored) { return rawData.toString() } } return rawData?.toString() } private Map decryptMobileResponseText(String text, String context = '') { if (!text) { logDebug "${context} had no readable body." return [:] } try { byte[] respBytes = text.getBytes('ISO-8859-1') String decrypted = winixAesDecrypt(respBytes) logDebug "${context} decrypted: ${decrypted}" return new JsonSlurper().parseText(decrypted) as Map } catch (Exception e) { log.warn "${context} failed to decrypt: ${e.message}" logDebug "${context} raw body (first 200 chars): ${text.take(200)}" return [:] } } /* Cognito */ private Map cognitoPost(String endpoint, String target, Map body) { Map params = [ uri : endpoint, headers : ['X-Amz-Target': target], requestContentType: 'application/x-amz-json-1.1', contentType : 'application/x-amz-json-1.1', body : JsonOutput.toJson(body), timeout : 20 ] Map result = [:] try { httpPost(params) { resp -> result = extractJsonMap(resp.data) } } catch (groovyx.net.http.HttpResponseException e) { result = extractJsonMap(e.response?.data) logDebug "Cognito call ${target} returned HTTP ${e.statusCode}: ${result}" } return result } private Map extractJsonMap(data) { if (data instanceof Map) return data String text = data instanceof String ? data : data?.text return text ? (new JsonSlurper().parseText(text) as Map) : [:] } /* Helpers */ void disableDebugLogging() { device.updateSetting('debugLoggingEnabled', [value: false, type: 'bool']) } void logDebug(String msg) { if (debugLoggingEnabled) { log.debug msg } }