What do I need at Ikea?

Next driver by ChatGPT : "IKEA ALPSTUGA Matter" :

/*
 * IKEA ALPSTUGA Matter Air Quality Monitor (minimal)
 * 
 * Last edited: 2026/01/03 10:24 AM
 *
 */

import hubitat.device.HubAction
import hubitat.device.Protocol

metadata {
    definition(name: "IKEA ALPSTUGA Matter", namespace: "community", author: "kkossev + ChatGPT") {
        capability "Sensor"
        capability "Switch"
        capability "TemperatureMeasurement"
        capability "RelativeHumidityMeasurement"
        capability "AirQuality"
        capability "CarbonDioxideMeasurement"
        capability "Refresh"
        capability "Initialize"
        
        attribute "airQuality", "string"
        attribute "pm25", "number"
    }
    preferences {
        input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: false
    }
}

void installed() { initialize() }

void updated() {
    if (logEnable) runIn(1800, "logsOff")
    initialize()
}

void logsOff() {
    device.updateSetting("logEnable", [value: "false", type: "bool"])
    log.warn "Debug logging disabled"
}

void initialize() {
    if (logEnable) log.debug "initialize()"
    subscribeToAttributes()
    refresh()
}

void refresh() {
    if (logEnable) log.debug "refresh()"
    List<Map<String,String>> paths = []
    paths.add(matter.attributePath(0x01, 0x0006, 0x0000)) // OnOff
    paths.add(matter.attributePath(0x01, 0x005B, 0x0000)) // AirQuality
    paths.add(matter.attributePath(0x01, 0x0402, 0x0000)) // Temperature
    paths.add(matter.attributePath(0x01, 0x0405, 0x0000)) // Humidity
    paths.add(matter.attributePath(0x01, 0x042A, 0x0000)) // PM2.5
    paths.add(matter.attributePath(0x01, 0x040D, 0x0000)) // CO2 MeasuredValue

    String cmd = matter.readAttributes(paths)
    sendHubCommand(new HubAction(cmd, Protocol.MATTER))
}

private void subscribeToAttributes() {
    List<Map<String,String>> paths = []
    paths.add(matter.attributePath(0x01, 0x0006, 0x0000))
    paths.add(matter.attributePath(0x01, 0x005B, 0x0000))
    paths.add(matter.attributePath(0x01, 0x0402, 0x0000))
    paths.add(matter.attributePath(0x01, 0x0405, 0x0000))
    paths.add(matter.attributePath(0x01, 0x042A, 0x0000))
    paths.add(matter.attributePath(0x01, 0x040D, 0x0000)) // CO2 MeasuredValue

    String cmd = matter.cleanSubscribe(1, 0xFFFF, paths)
    sendHubCommand(new HubAction(cmd, Protocol.MATTER))

    if (txtEnable) log.info "Subscribed to ALPSTUGA: on/off + air quality + temp + humidity + PM2.5"
}

def parse(String description) {

    Map msg = matter.parseDescriptionAsMap(description)
    if (logEnable) log.debug "parse: ${description} msg: ${msg}"
    if (!msg) return

    Integer ep     = safeHexToInt(msg.endpoint)
    Integer clus   = safeHexToInt(msg.cluster)
    Integer attrId = safeHexToInt(msg.attrId)
    String value   = msg.value?.toString()

    if (ep == null || clus == null || attrId == null) return

    // EP01 only (based on your logs)
    if (ep != 0x01) {
        if (logEnable) log.debug "Ignoring EP${msg.endpoint}"
        return
    }

    // On/Off: cluster 0x0006 attr 0x0000 (Hubitat shows "value:01" even if raw line says 09)
    if (clus == 0x0006 && attrId == 0x0000) {
        Integer v = safeHexToInt(value)
        if (v != null) {
            String sw = (v != 0) ? "on" : "off"
            sendEvent(name: "switch", value: sw, descriptionText: txtEnable ? "Switch is ${sw}" : null)
            if (txtEnable) log.info "Switch is ${sw}"
        }
        return
    }

    // Air Quality: cluster 0x005B attr 0x0000 (you saw "value:01" -> good)
    if (clus == 0x005B && attrId == 0x0000) {
        Integer aq = safeHexToInt(value)
        if (aq != null) {
            String aqText = airQualityToText(aq)
            sendEvent(name: "airQuality", value: aqText, descriptionText: txtEnable ? "Air quality is ${aqText}" : null)
            sendEvent(name: "airQualityIndex", value: aq)
            if (txtEnable) log.info "Air quality is ${aqText} (index: ${aq})"
        }
        return
    }

    // Temperature: cluster 0x0402 attr 0x0000 (0.01 °C)
    if (clus == 0x0402 && attrId == 0x0000) {
        Integer raw = safeHexToInt(value)   // e.g. 0x0927 => 2343 => 23.43°C
        if (raw != null) {
            BigDecimal c = ((short) raw) / 100.0
            BigDecimal cRounded = c.setScale(1, BigDecimal.ROUND_HALF_UP)

            // Convert to hub's scale if needed (F hubs will get °F)
            def t = convertTemperatureIfNeeded(cRounded, "C", 1)

            String unit = (location.temperatureScale == "F") ? "°F" : "°C"
            sendEvent(
                name: "temperature",
                value: t,
                unit: unit,
                descriptionText: txtEnable ? "Temperature is ${t} ${unit}" : null
            )
            if (txtEnable) log.info "Temperature is ${t} ${unit}"
        }
        return
    }

    // Humidity: cluster 0x0405 attr 0x0000 (0.01 %)
    if (clus == 0x0405 && attrId == 0x0000) {
        Integer raw = safeHexToInt(value)   // e.g. 0x135C => 4956 => 49.56%
        if (raw != null) {
            BigDecimal rh = (raw / 100.0).setScale(1, BigDecimal.ROUND_HALF_UP)
            sendEvent(name: "humidity", value: rh, unit: "%",
                      descriptionText: txtEnable ? "Humidity is ${rh}%" : null)
            if (txtEnable) log.info "Humidity is ${rh}%"
        }
        return
    }

    // PM2.5: cluster 0x042A attr 0x0000
    // Hubitat shows value like 40A00000 -> IEEE754 float bits (5.0)
    if (clus == 0x042A && attrId == 0x0000) {
        Integer bits = safeHexToInt(value)
        if (bits != null) {
            float pm = Float.intBitsToFloat(bits)
            Integer pmInt = Math.round(pm)
            sendEvent(name: "pm25", value: pmInt, unit: "µg/m³",
                      descriptionText: txtEnable ? "PM2.5 is ${pmInt} µg/m³" : null)
            if (txtEnable) log.info "PM2.5 is ${pmInt} µg/m³"
        }
        return
    }
    
    // CO2: cluster 0x040D attr 0x0000
    // Hubitat shows value like 44AF6000 -> IEEE754 float bits (e.g. 1403.0)
    if (clus == 0x040D && attrId == 0x0000) {
        Integer bits = safeHexToInt(value)
        if (bits != null) {
            float co2f = Float.intBitsToFloat(bits)
            Integer co2 = Math.round(co2f)   // ppm as integer
            sendEvent(name: "carbonDioxide", value: co2, unit: "ppm",
                      descriptionText: txtEnable ? "COâ‚‚ is ${co2} ppm" : null)
            if (txtEnable) log.info "COâ‚‚ is ${co2} ppm"
        }
        return
    }
    
    if (logEnable) log.debug "Unhandled Matter report: ${msg}"
}

void on() {
    String cmd = matter.on()
    sendHubCommand(new HubAction(cmd, Protocol.MATTER))
}

void off() {
    String cmd = matter.off()
    sendHubCommand(new HubAction(cmd, Protocol.MATTER))
}

private static String airQualityToText(Integer v) {
    // Keep this mapping minimal and safe.
    // You observed: 1 -> "good"
    switch (v) {
        case 0: return "unknown"
        case 1: return "good"
        case 2: return "fair"
        case 3: return "moderate"
        case 4: return "poor"
        case 5: return "very poor"
        case 6: return "extremely poor"
        default: return "unknown"
    }
}

private Integer safeHexToInt(Object hex) {
    if (hex == null) return null
    String s = hex.toString().trim()
    if (s.startsWith("0x") || s.startsWith("0X")) s = s.substring(2)
    if (s == "") return null
    try { return Integer.parseUnsignedInt(s, 16) } catch (Exception ignored) { return null }
}

image

2 Likes