/** * Hubitat Device Driver * Winix Purifier * 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.JsonOutput @Field static final Map AIRFLOW_TO_CODE = [ low : '01', medium : '02', high : '03', turbo : '05', sleepMode: '06' ] @Field static final Map CODE_TO_AIRFLOW = [ '01': 'low', '02': 'medium', '03': 'high', '05': 'turbo', '06': 'sleepMode' ] @Field static final Map DASHBOARD_SAFE_SPEED_TO_WINIX = [ low: 'low', medium: 'medium', 'medium-high': 'high', high: 'turbo' ] @Field static final Map GOOGLE_HOME_SPEED_TO_WINIX = [ low: 'low', medium: 'medium', high: 'high', turbo: 'turbo' ] @Field static final Map DASHBOARD_SAFE_WINIX_TO_SPEED = [ low: 'low', medium: 'medium', high: 'medium-high', turbo: 'high', sleepMode: 'low' ] @Field static final Map GOOGLE_HOME_WINIX_TO_SPEED = [ low: 'low', medium: 'medium', high: 'high', turbo: 'turbo', sleepMode: 'low' ] @Field static final Map CODE_TO_AIR_QUALITY = [ '01': 'good', '02': 'fair', '03': 'poor', '04': 'veryPoor' ] @Field static final Map AIR_QUALITY_INDEX = [ good: 1, fair: 2, poor: 3, veryPoor: 4 ] @Field static final List FAN_SPEEDS = ['low', 'medium', 'high', 'turbo', 'sleepMode'] @Field static final List ON_OFF = ['on', 'off'] @Field static final List DASHBOARD_SAFE_FAN_SPEEDS = ['low', 'medium', 'medium-high', 'high', 'auto'] @Field static final List GOOGLE_HOME_FAN_SPEEDS = ['low', 'medium', 'high', 'turbo', 'auto'] @Field static final List DASHBOARD_SAFE_CYCLE_SPEEDS = ['low', 'medium', 'medium-high'] @Field static final List GOOGLE_HOME_CYCLE_SPEEDS = ['low', 'medium', 'high'] @Field static final Map BRIGHTNESS_TO_CODE = [ off: '0', low: '30', medium: '70', high: '100' ] @Field static final Map CODE_TO_BRIGHTNESS = [ '0': 'off', '30': 'low', '70': 'medium', '100': 'high' ] @Field static final List BRIGHTNESS_LEVELS = ['off', 'low', 'medium', 'high'] // Winix reports filter life as a number of months (e.g. "9"); this is the // fallback used if a device's discovery data doesn't include it. @Field static final Integer DEFAULT_FILTER_MAX_MONTHS = 9 @Field static final Integer HOURS_PER_MONTH = 720 metadata { definition( name: 'Winix Purifier', namespace: 'cogdev', author: 'Seth Kinast ', importUrl: 'https://raw.githubusercontent.com/sethkinast/hubitat-winix/master/winix-purifier.groovy' ) { capability 'Switch' capability 'Refresh' capability 'Initialize' capability 'Actuator' capability 'Sensor' capability 'FanControl' command 'low' command 'medium' command 'high' command 'turbo' command 'sleepMode' command 'auto' command 'setPlasmawave', [[name: 'State*', type: 'ENUM', constraints: ON_OFF]] command 'setChildLock', [[name: 'State*', type: 'ENUM', constraints: ON_OFF]] command 'setBrightness', [[name: 'Level*', type: 'ENUM', constraints: BRIGHTNESS_LEVELS]] attribute 'mode', 'enum', ['auto', 'manual'] attribute 'fanSpeed', 'enum', FAN_SPEEDS attribute 'plasmawave', 'enum', ON_OFF attribute 'childLock', 'enum', ON_OFF attribute 'brightness', 'enum', BRIGHTNESS_LEVELS attribute 'supportedFanSpeeds', 'JSON_OBJECT' attribute 'airQuality', 'enum', ['good', 'fair', 'poor', 'veryPoor'] attribute 'airQualityIndex', 'number' attribute 'airQvalue', 'number' attribute 'ambientLight', 'number' attribute 'pm25', 'number' attribute 'filterHours', 'number' attribute 'filterLifePercent', 'number' } } preferences { section('Polling') { input name: 'pollingIntervalSeconds', type: 'number', title: 'Polling interval (seconds)', defaultValue: 60, range: '15..3600' } section('Fan Control') { input name: 'hubitatDashboardCompatibilityMode', type: 'bool', title: 'Hubitat Dashboard compatibility mode. Turn on to remap turbo to high', defaultValue: false } section('Filter') { input name: 'filterMaxLifeHours', type: 'number', title: 'Filter life override (hours) ' + "(typically ${DEFAULT_FILTER_MAX_MONTHS * HOURS_PER_MONTH} hours = ${DEFAULT_FILTER_MAX_MONTHS} months x ${HOURS_PER_MONTH} hrs/month). " + 'Cannot exceed the Winix-reported max for this device.', required: false } 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() state.commandQueue = [] state.commandExecuting = false refreshDeviceInfo() } void refreshDeviceInfo() { sendEvent(name: 'supportedFanSpeeds', value: JsonOutput.toJson(activeFanSpeeds())) scheduleNextPoll() runIn(3, 'poll') } private List activeFanSpeeds() { hubitatDashboardCompatibilityMode ? DASHBOARD_SAFE_FAN_SPEEDS : GOOGLE_HOME_FAN_SPEEDS } private Map activeSpeedToWinix() { hubitatDashboardCompatibilityMode ? DASHBOARD_SAFE_SPEED_TO_WINIX : GOOGLE_HOME_SPEED_TO_WINIX } private Map activeWinixToSpeed() { hubitatDashboardCompatibilityMode ? DASHBOARD_SAFE_WINIX_TO_SPEED : GOOGLE_HOME_WINIX_TO_SPEED } private List activeCycleSpeeds() { hubitatDashboardCompatibilityMode ? DASHBOARD_SAFE_CYCLE_SPEEDS : GOOGLE_HOME_CYCLE_SPEEDS } /* Commands */ void on() { boolean wasOff = device.currentValue('switch') != 'on' powerOnIfOff() if (wasOff) { restoreStateAfterPowerOn() } } void off() { state.commandQueue = [] state.commandExecuting = false sendControl('A02', '0') sendEvent(name: 'switch', value: 'off') sendEvent(name: 'speed', value: 'off') } private void restoreStateAfterPowerOn() { String lastMode = device.currentValue('mode') as String String lastSpeed = device.currentValue('fanSpeed') as String if (lastMode == 'manual' && lastSpeed) { restoreSpeed(lastSpeed) } else { auto() } String lastBrightness = device.currentValue('brightness') as String if (lastBrightness) { setBrightness(lastBrightness) } String lastChildLock = device.currentValue('childLock') as String if (lastChildLock) { setChildLock(lastChildLock) } } private void restoreSpeed(String speed) { switch (speed) { case 'low': case 'medium': case 'high': case 'turbo': case 'sleepMode': String code = AIRFLOW_TO_CODE[speed] enqueueControl('A03', '02') enqueueControl('A04', code) reportSpeed(speed) sendEvent(name: 'mode', value: 'manual') break default: logDebug "Unrecognized fanSpeed to restore: ${speed}" } } void auto() { powerOnIfOff() sendControl('A03', '01') sendEvent(name: 'mode', value: 'auto') } void low() { setAirflow(AIRFLOW_TO_CODE.low) } void medium() { setAirflow(AIRFLOW_TO_CODE.medium) } void high() { setAirflow(AIRFLOW_TO_CODE.high) } void turbo() { setAirflow(AIRFLOW_TO_CODE.turbo) } void sleepMode() { setAirflow(AIRFLOW_TO_CODE.sleepMode) } private void setAirflow(String code) { powerOnIfOff() enqueueControl('A03', '02') enqueueControl('A04', code) reportSpeed(CODE_TO_AIRFLOW[code] ?: code) sendEvent(name: 'mode', value: 'manual') } private void reportSpeed(String friendlySpeed) { sendEvent(name: 'fanSpeed', value: friendlySpeed) sendEvent(name: 'speed', value: activeWinixToSpeed()[friendlySpeed] ?: 'off') } private void updateCoarseSpeedFromPoll(Map attrs) { if (attrs['A02'] == '0') { sendEvent(name: 'speed', value: 'off') return } String airflowCode = attrs['A04'] as String if (airflowCode != null) { String friendly = CODE_TO_AIRFLOW[airflowCode] ?: airflowCode sendEvent(name: 'speed', value: activeWinixToSpeed()[friendly] ?: 'off') } } private void powerOnIfOff() { if (device.currentValue('switch') != 'on') { sendControl('A02', '1') sendEvent(name: 'switch', value: 'on') } } void setSpeed(String speed) { switch (speed) { case 'low': low(); return case 'medium-low': case 'medium': medium(); return case 'auto': auto(); return case 'on': on(); return case 'off': off(); return } String winixLevel = activeSpeedToWinix()[speed] switch (winixLevel) { case 'high': high(); break case 'turbo': turbo(); break default: log.warn "Unsupported standard fan speed: ${speed}" } } void cycleSpeed() { List speeds = activeCycleSpeeds() String current = device.currentValue('speed') as String int index = speeds.indexOf(current) String next = speeds[(index + 1) % speeds.size()] setSpeed(next) } void setPlasmawave(String state) { sendControl('A07', state == 'on' ? '1' : '0') } void setChildLock(String state) { sendControl('A08', state == 'on' ? '1' : '0') } void setBrightness(String level) { String code = BRIGHTNESS_TO_CODE[level] if (!code) { log.warn "Unsupported brightness level: ${level}" return } sendControl('A16', code) } void refresh() { unschedule('poll') poll() } /* Control (write) calls */ private void sendControl(String attrCode, String value) { enqueueControl(attrCode, value) } private void enqueueControl(String attrCode, String value) { List queue = (state.commandQueue as List) ?: [] queue.add([code: attrCode, val: value]) state.commandQueue = queue if (!state.commandExecuting) { processNextCommand() } } private void processNextCommand() { List queue = (state.commandQueue as List) ?: [] if (queue.isEmpty()) { state.commandExecuting = false unschedule('poll') runIn(5, 'poll') return } if (rateLimited()) { log.warn 'Skipping command queue execution - Winix API rate limit cooldown in effect.' state.commandQueue = [] state.commandExecuting = false return } String deviceId = winixDeviceId() String identityId = parent?.getIdentityId() if (!deviceId || !identityId) { log.warn "Winix purifier not ready yet (missing deviceId or identityId - is the parent 'Winix Account' device logged in?)." state.commandQueue = [] state.commandExecuting = false return } state.commandExecuting = true Map nextCmd = queue.remove(0) as Map state.commandQueue = queue String url = "https://us.api.winix-iot.com/common/control/devices/${deviceId}/${identityId}/${nextCmd.code}:${nextCmd.val}" asynchttpGet('controlCallback', [uri: url, timeout: 15]) } void controlCallback(resp, data) { if (resp.status == 403 || resp.status == 429) { startCooldown() log.warn 'Winix API rate limited; backing off for 60s.' state.commandQueue = [] state.commandExecuting = false unschedule('poll') runIn(65, 'poll') return } if (resp.hasError()) { log.warn "Winix control call failed: ${resp.getErrorMessage()}" } processNextCommand() } /* Status (read) polling */ void poll() { unschedule('poll') String deviceId = winixDeviceId() if (!deviceId) { logDebug 'No deviceId yet; skipping poll.' return } if (rateLimited()) { unschedule('poll') runIn(65, 'poll') return } String url = "https://us.api.winix-iot.com/common/event/sttus/devices/${deviceId}" asynchttpGet('statusCallback', [uri: url, timeout: 15]) } void statusCallback(resp, data) { if (resp.status == 403 || resp.status == 429) { startCooldown() log.warn 'Winix API rate limited; backing off for 60s.' unschedule('poll') runIn(65, 'poll') return } scheduleNextPoll() if (resp.hasError()) { log.warn "Winix status poll failed: ${resp.getErrorMessage()}" return } try { Map json = resp.getJson() as Map String resultMessage = json?.headers?.resultMessage if (resultMessage == 'no data') { logDebug "Winix returned 'no data' for this poll." return } List dataList = json?.body?.data as List if (!dataList) { logDebug "Winix status response had no data array: ${json}" return } Map attrs = (dataList[0] as Map)?.attributes as Map if (attrs) { applyAttributes(attrs) } } catch (Exception e) { log.warn "Error parsing Winix status response: ${e.message}" } } private void applyAttributes(Map attrs) { attrs.each { code, raw -> applyAttribute(code as String, raw as String) } updateCoarseSpeedFromPoll(attrs) device.updateDataValue('lastUpdate', new Date().format('yyyy-MM-dd HH:mm:ss')) } private void applyAttribute(String code, String value) { switch (code) { case 'A02': sendEvent(name: 'switch', value: value == '1' ? 'on' : 'off') break case 'A03': sendEvent(name: 'mode', value: value == '01' ? 'auto' : 'manual') break case 'A04': sendEvent(name: 'fanSpeed', value: CODE_TO_AIRFLOW[value] ?: value) break case 'A07': sendEvent(name: 'plasmawave', value: value == '1' ? 'on' : 'off') break case 'A08': sendEvent(name: 'childLock', value: value == '1' ? 'on' : 'off') break case 'A16': sendEvent(name: 'brightness', value: CODE_TO_BRIGHTNESS[value] ?: value) break case 'A21': applyFilterHours(safeInt(value)) break case 'S04': Integer pm25 = safeInt(value) if (pm25 != null) sendEvent(name: 'pm25', value: pm25, unit: 'ug/m3') break case 'S07': String aq = CODE_TO_AIR_QUALITY[value] ?: value sendEvent(name: 'airQuality', value: aq) sendEvent(name: 'airQualityIndex', value: AIR_QUALITY_INDEX[aq] ?: 0) break case 'S08': Integer qv = safeInt(value) if (qv != null) sendEvent(name: 'airQvalue', value: qv) break case 'S14': Integer light = safeInt(value) if (light != null) sendEvent(name: 'ambientLight', value: light) break default: logDebug "Unhandled Winix attribute code ${code}=${value}" } } private void applyFilterHours(Integer hours) { if (hours == null) return sendEvent(name: 'filterHours', value: hours) Integer maxLife = effectiveFilterMaxLifeHours() if (maxLife > 0) { int pct = Math.max(0, 100 - Math.round((hours / (float) maxLife) * 100) as int) sendEvent(name: 'filterLifePercent', value: pct, unit: '%') } } private Integer effectiveFilterMaxLifeHours() { Integer filterMaxPeriodMonths = safeInt(device.getDataValue('winixFilterMaxPeriodMonths')) Integer apiMaxHours = (filterMaxPeriodMonths ?: DEFAULT_FILTER_MAX_MONTHS) * HOURS_PER_MONTH Integer userHours = filterMaxLifeHours as Integer if (userHours == null || userHours <= 0) { return apiMaxHours } return Math.min(userHours, apiMaxHours) } private Integer safeInt(String s) { try { return s?.isInteger() ? s.toInteger() : null } catch (Exception ignored) { return null } } /* Scheduling / rate limiting */ private void scheduleNextPoll() { unschedule('poll') Integer interval = (pollingIntervalSeconds as Integer) ?: 60 if (interval < 15) interval = 15 runIn(interval, 'poll') } private boolean rateLimited() { Long cooldownUntil = state.cooldownUntil as Long return cooldownUntil && now() < cooldownUntil } private void startCooldown() { state.cooldownUntil = now() + 60000L } private String winixDeviceId() { return device.getDataValue('winixDeviceId') as String } /* Helpers */ void disableDebugLogging() { device.updateSetting('debugLoggingEnabled', [value: false, type: 'bool']) } void logDebug(String msg) { if (debugLoggingEnabled) { log.debug msg } }