Xiaomi smart light sensor

Please find here following. Please consider also it is still to clean up and to refine.
Even more the value is illuminance not lux. I'll post again here asap it is finished.
But .. it works.

pure illuminance device. fast and precise. it is possible to configure min and max time for reporting (illuminance reading) that is the minimum and maximum interval between value readings. In my driver it is configurable (but it is only for debug)

/**
 *  My Xiaomi Mijia Smart Light Sensor
 *
 *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 *  in compliance with the License. You may obtain a copy of the License at:
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
 *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
 *  for the specific language governing permissions and limitations under the License.
 **/

// see:https://github.com/dresden-elektronik/deconz-rest-plugin/issues/2300
// see:https://community.smartthings.com/t/release-xiaomi-mijia-smart-light-sensor-march-2020/189266
// see:https://stdavedemo.readthedocs.io/en/latest/device-type-developers-guide/zigbee-example.html

import hubitat.zigbee.zcl.DataType
import hubitat.helper.HexUtils

import groovy.json.JsonOutput
import groovy.json.JsonSlurper

import java.math.BigDecimal

def getVersionNum() { return "0.0.2" }
private def getVersionLabel() { return "Xiaomi Mijia Smart Light Sensor" }

metadata {
    definition (name: "My Xiaomi Mijia Smart Light Sensor", namespace: "r.brunialti", author: "r.brunialti") {
        capability "Illuminance Measurement"
        capability "Sensor"
        capability "Battery"
        capability "Refresh"
        
        attribute "batteryLastReplaced", "String"
		attribute "lastCheckin", "String"
		attribute "device", "String"
        attribute "illuminance", "number"
    
        //fingerprint profileId: "0104", inClusters: "0000,0400,0003,0001", outClusters: "0003", manufacturer: "LUMI", model: "lumi.sen_ill.mgl01", deviceJoinName: "Xiaomi Mijia Smart Home Light Sensor", ocfDeviceType: "oic.r.sensor.illuminance"
        fingerprint profileId: "0104", inClusters: "0000,0400,0003,0001", outClusters: "0003", manufacturer: "LUMI", model: "lumi.sen_ill.mgl01", deviceJoinName: "Xiaomi Mijia Smart Home Light Sensor"

        command "resetBatteryReplacedDate"
        command "configure"
        command "refresh"
    }
    preferences {
        input name: "luxOffset", title: "Lux Value Offset",  description: "", range: "*..*"
		input name: "secondsMinLux", title: "Refresh minimum time", description: "Default = 3000 ms", type: "decimal", range: "*..*" 
		input name: "secondsMaxLux", title: "Refresh maximum time", description: "Default = 30000 ms", type: "decimal", range: "*..*" 
		input name: "minTriggerLux", title: "Minimum value change to report", description: "Default = 1", type: "decimal", range: "*..*" 
		//Battery Voltage Range
 		input name: "voltsmin", title: "Min Volts (0% battery = ___ volts, range 2.0 to 2.9). Default = 2.5 Volts", description: "", type: "decimal", range: "2..2.7"
 		input name: "voltsmax", title: "Max Volts (100% battery = ___ volts, range 2.8 to 3.5). Default = 3.0 Volts", description: "Default = 3.0 Volts", type: "decimal", range: "2.8..3.4"
 		//Logging Message Config
		input name: "infoLogging", type: "bool", title: "Enable info message logging", description: ""
		input name: "debugLogging", type: "bool", title: "Enable debug message logging", description: ""   
    }
}

def parse(String description) {
    Map map = [:]
    def descMap = zigbee.parseDescriptionAsMap(description)
    displayDebugLog("Received message: ${description}")  
    displayDebugLog("Parsed message: ${descMap}")  

    //sendEvent(name: "lastCheckin", value: new Date().format("dd/mm/yy hh:mm:ss a", location.timeZone))
    sendEvent(name: "lastCheckin", value: new Date())
    
    if (description?.startsWith('read attr - raw:')) {
		description = description - "read attr - raw:"
    	def encoding = Integer.parseInt(descMap.encoding, 16)
        if (descMap.value != null & encoding > 0x18 & encoding < 0x3e) 
            displayDebugLog("Reversed payload value: ${descMap.value}")
/*
        if (descMap.value != null & encoding > 0x18 & encoding < 0x3e) {
    		displayDebugLog("C1 - Data type of payload is little-endian; parseDescriptionAsMap reversed order")
    		// Reverse order of bytes in description's payload for LE data types - required for Hubitat firmware 2.0.5 or newer
    		// descMap.value = reverseHexString(descMap.value)
            displayDebugLog("C2 - Reversed payload value: ${descMap.value}")
        }
*/
        // Send message data to appropriate parsing function based on the type of report    
    	if (descMap.cluster == "0000" & descMap.attrInt == 5)
    		// Button short-pressed
            displayDebugLog("Reset button of [${descMap.value}] was short-pressed")
        else if (descMap.cluster == "0400" & descMap.attrInt == 0)
            // Parse illuminance value report
    		map = parseIlluminance(descMap.value)
    	else if (descMap.cluster == "0001" & descMap.attrInt == 32)
    		// Parse battery level from hourly announcement message
            map getBatteryResult(descMap.value)
        else
            displayDebugLog("Unknown cluster/attribute: cluster=${descMap.cluster}, attribute=${descMap.attrId}")
    } else if (description?.startsWith('catchall:')) { 
	    map = parseCatchAllMessage(description)
        displayDebugLog("Trapped catchall: cluster=${descMap.cluster}")
        // cluster=="0600" ->  OTA update request
        // cluster=="0300" & command==1 -> IDENTIFY request response
    } else
        displayDebugLog("Unknown message type")
        
	if (map != [:]) {
		displayInfoLog("$map.descriptionText")
		return createEvent(map)   
    }
}

//============================================
// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
private Map parseCatchAllMessage(description) {
	Map resultMap = [:]
	def catchall = zigbee.parse(description)
	def MsgLength = catchall.data.size()
    
	if (catchall.clusterId == 0x0000) {
        // Xiaomi Catchall does not have identifiers, first UINT16 is Battery
	    if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
		    for (int i = 4; i < (MsgLength-3); i++) {
			    if (catchall.data.get(i) == 0x21) { // check the data ID and data type
				    // next two bytes are the battery voltage
				    resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
				    break
			    }
            }
		}
	}
	return resultMap
}

// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
private Map getBatteryResult(rawValue) {
	// raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
	// but in the case the final zero is dropped then divide by 100 to get actual voltage value
	def rawVolts = rawValue / 1000
	def minVolts = voltsmin ? voltsmin : 2.5
	def maxVolts = voltsmax ? voltsmax : 3.0
	def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
	def roundedPct = Math.min(100, Math.round(pct * 100))
	displayInfoLog(": $descText")
	return [
		name: 'battery',
		value: roundedPct,
		unit: "%",
		isStateChange:true,
		descriptionText : "Battery at ${roundedPct}% (${rawVolts} Volts)"
	]
}

//
private parseIlluminance(rawValue) {
	def offset = luxOffset ? luxOffset : 0
    Integer value = Integer.parseInt(rawValue, 16)

    //illuminance -> lux = 10,000 x log10(Illuminance) + 1
    BigDecimal lux = value > 0 ? Math.pow(10, value / 10000.0) - 1.0 : 0
    lux = lux.setScale(2, BigDecimal.ROUND_HALF_UP)
	return [
		name: 'illuminance',
		value: lux,
		unit: 'lux',
		isStateChange: true,
		descriptionText: "Illuminance is ${lux} lux"
	]
}

// Convert raw 4 digit integer voltage value -> percentage based on minVolts/maxVolts range
private parseBattery(description) {
	displayDebugLog("Battery parse string = ${description}")
	def MsgLength = description.size()
	def rawValue
	for (int i = 4; i < (MsgLength-3); i+=2) {
		if (description[i..(i+1)] == "21") { // Search for byte preceeding battery voltage bytes
			rawValue = Integer.parseInt((description[(i+4)..(i+5)] + description[(i+2)..(i+3)]),16)
			break
		}
	}
	def rawVolts = rawValue / 1000
	def minVolts = voltsmin ? voltsmin : 2.9
	def maxVolts = voltsmax ? voltsmax : 3.05
	def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
	def roundedPct = Math.min(100, Math.round(pct * 100))
	def descText = "Battery level is ${roundedPct}% (${rawVolts} Volts)"
	if (lastCheckinEnable) {
		sendEvent(name: "lastCheckinEpoch", value: now())
	}
	displayInfoLog(descText)
	return [
		name: 'battery',
		value: roundedPct,
		unit: "%",
		descriptionText: descText
	]
}

// installed() runs just after a sensor is paired
def installed() {
	displayDebugLog("Installing")
	state.prefsSetCount = 0
	init()
}

//
def init() {
configure()
}
    
// updated() will run every time user saves preferences
def updated() {
	displayInfoLog("Updating preference settings")
	init()
}

//Reset the batteryLastReplaced date to current date
def resetBatteryReplacedDate(paired) {
	displayInfoLog("Setting Battery Last Replace date")
	sendEvent(name: "batteryLastReplaced", value: new Date())
    }

//
private def displayDebugLog(message) {
	if (debugLogging) 
        log.debug "${device.displayName}: ${message}"
}

// Reverses order of bytes in hex string
def reverseHexString(hexString) {
	def reversed = ""
	for (int i = hexString.length(); i > 0; i -= 2) {
		reversed += hexString.substring(i - 2, i )
	}
	return reversed
}

//
private def displayInfoLog(message) {
	if (infoLogging || state.prefsSetCount != 1)
		log.info "${device.displayName}: ${message}"
}

//
def configure() {
	displayDebugLog("Configuration starting")
    displayDebugLog("...bindings and reporting")
    List<String> cmds = []

    sendEvent(name: "device", value: getVersionLabel())
    
    Integer minReportTime = secondsMinLux == null ? 30  : secondsMinLux.toInteger()
    Integer maxReportTime = secondsMaxLux == null ? 120 : secondsMaxLux.toInteger()
    Integer minLuminance = minTriggerLux == null ? 1 :   minTriggerLux.toInteger()
    
    cmds.addAll(
        "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x001 {${device.zigbeeId}} {}", // identify cluster
        "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x000 {${device.zigbeeId}} {}", // identify cluster
        "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x003 {${device.zigbeeId}} {}", // identify cluster
		"zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x400 {${device.zigbeeId}} {}", // illuminance cluster
		"send 0x${device.deviceNetworkId} 1 1",
        "he cr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0001 0x0020 0x20 60 3600 {01}",  // identify cluster
        "he cr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0000 0x0005 0xff 30 3600 {}",    // identify cluster
        "he cr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0003 0x0000 0xff 1 36000 {}",    // identify cluster
        "he cr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0400 0x0000 0x21 ${minReportTime} ${maxReportTime} {01}" // illuminance cluster
    )

    //sendHubCommand(new HubMultiAction(delayBetween(cmds,200), Protocol.ZIGBEE))
    displayDebugLog("...returning commands: ${cmds+refresh()}")
    return cmds
}

// configure() runs after installed() when a sensor is paired
// refresh() runned from init() and command refresh
def refresh() {
	displayDebugLog("...refreshing values")
    List<String> cmds = []
    Integer msDelay = 200

    cmds.addAll(
        "he rattr 0x${device.deviceNetworkId} 1 0x000 0", "delay $msDelay",
        "he rattr 0x${device.deviceNetworkId} 1 0x001 0", "delay $msDelay",
        "he rattr 0x${device.deviceNetworkId} 1 0x003 0", "delay $msDelay",
        "he rattr 0x${device.deviceNetworkId} 1 0x400 0", "delay $msDelay")
	
    return cmds
}

void sendZigbeeCommands(ArrayList<String> cmd) {
    displayDebugLog("sendZigbeeCommands($cmd)")
    hubitat.device.HubMultiAction allActions = new hubitat.device.HubMultiAction()
    cmd.each {
    //displayDebugLog(">>>>>$it")
        if(it.startsWith('delay')) {
            allActions.add(new hubitat.device.HubAction(it))
        } else {
            allActions.add(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE))
        }
    }
    sendHubCommand(allActions)
}

EDIT: now it should report the correct lux value.

3 Likes

Did you follow the post-pairing instructions? Until the device receives and accepts the configuration with the bind and report config it will not send any data to HE. Since it is battery powered it only receives commands right after pressing the button on the device. If there are connectivity issues on your mesh these commands may not arrive.

Maybe the driver can be made to do this with better feedback and in an easier way, but as is mentioned, it is an early release so that the community get to have something that works. @roberto is now doing the same thing in his driver as mine for the binding and configuration and the differences are in other areas.

Ciao Roberto
I have some problem with the Xiaomi GZCGQ01LM
once paired it loose the connection after 30s/1 minute.
Do you have suggestion?
i don't know how to solve it
Thanks

Sorry for the delay. I'm not a compulsive frequent visitor of the community.
You could try the markus's drivers at : Hubitat/drivers/expanded at development ยท markus-li/Hubitat ยท GitHub
Hope it can help you

1 Like

Already done does not work

With this driver, I am seeing the not present counter but since I initially put this in, in which I did get a reading, I have not gotten an illuminance since. It has not dropped off the network. Just has not provided the illuminance reading.

I know this is over a year old... been using your driver with success, thanks! I do have two questions: 1) It seems that no matter what values I set for Min/Max report times, it reports every 2 minutes...? and 2) I never get any reports of battery level. Any insights?

The illuminance levels seem good, and so far (about a week) the device has not dropped off the network, so it seems I'm good.

Unfortunately It seems Xiaomi does not accept standard report setting commandos...

Been working perfectly for me, except no battery report, so I guess I'll just have to replace the battery on some schedule once I figure out how long it lasts.

I've tweaked the LUX level triggers and have a DARK boolean variable that I set if it falls below threshold and stays like that for 15 minutes (to avoid light turning on for passing cloud, but have them come on for lasting storm). I use the DARK variable as a per-requisite for my "getting dark" and "getting bright" apps. (I also use sunrise-to-sunset time window, so it doesn't trigger all night). Was fun to see the lights come on a couple of weeks ago during a big storm, then go out once it stayed bright for 10 minutes. Thanks!

This topic was automatically closed 365 days after the last reply. New replies are no longer allowed.