/** * Candeo C-ZB-SEMO Zigbee Motion Sensor * Reports Motion Events * Reports Illuminance Events * Reports Battery Events * Has Setting For Battery Reporting */ metadata { definition(name: 'Candeo C-ZB-SEMO Zigbee Motion Sensor', namespace: 'Candeo', author: 'Candeo', importUrl: 'https://raw.githubusercontent.com/candeosmart/hubitat-zigbee/refs/heads/main/Candeo%20C-ZB-SEMO%20Zigbee%20Motion%20Sensor.groovy', singleThreaded: true) { capability 'MotionSensor' capability 'IlluminanceMeasurement' capability 'Battery' capability 'Sensor' capability 'Configuration' fingerprint profileId: '0104', endpointId: '01', inClusters: '0000,0003,0500,0001,0400', manufacturer: 'Candeo', model: 'C-ZB-SEMO', deviceJoinName: 'Candeo C-ZB-SEMO Zigbee Motion Sensor' } preferences { input name: 'deviceDriverOptions', type: 'hidden', title: 'Device Driver Options', description: 'The following options change the behaviour of the device driver, they take effect after hitting "Save Preferences below."' input name: 'loggingOption', type: 'enum', title: 'Logging Option', description: 'Sets the logging level cumulatively, for example "Driver Trace Logging" will include all logging levels below it.

', options: PREFLOGGING, defaultValue: '5' input name: 'deviceConfigurationOptions', type: 'hidden', title: 'Device Configuration Options', description: 'The following options change the behaviour of the device itself, they take effect after hitting "Save Preferences below", followed by "Configure" above.

For a battery powered device, you may also need to wake it up manually!
' input name: 'batteryPercentageReportTime', type: 'enum', title: 'Battery Percentage Time (hours)', description: 'Adjust the period that the battery percentage is reported to suit your requirements.

', options: PREFBATTERYREPORTTIME, defaultValue: '28800' input name: 'platformOptions', type: 'hidden', title: 'Platform Options', description: 'The following options are relevant to the Hubitat platform and UI itself.' } } import groovy.transform.Field private @Field final String CANDEO = 'Candeo C-ZB-SEMO Device Driver' private @Field final Boolean DEBUG = false private @Field final Integer LOGSOFF = 1800 private @Field final Integer ZIGBEEDELAY = 1000 private @Field final Map PREFFALSE = [value: 'false', type: 'bool'] private @Field final Map PREFTRUE = [value: 'true', type: 'bool'] private @Field final Map PREFBATTERYREPORTTIME = ['3600': '1h', '5400': '1.5h', '7200': '2h', '10800': '3h', '21600': '6h', '28800': '8h', '43200': '12h', '64800': '18h'] private @Field final Map PREFLOGGING = ['0': 'Device Event Logging', '1': 'Driver Informational Logging', '2': 'Driver Warning Logging', '3': 'Driver Error Logging', '4': 'Driver Debug Logging', '5': 'Driver Trace Logging' ] void installed() { logsOn() logTrace('installed called') device.updateSetting('batteryPercentageReportTime', [value: '28800', type: 'enum']) logInfo("batteryPercentageReportTime setting is: ${PREFBATTERYREPORTTIME[batteryPercentageReportTime]}") logInfo('logging level is: Driver Trace Logging') logInfo("logging level will reduce to Driver Error Logging after ${LOGSOFF} seconds") } void uninstalled() { logTrace('uninstalled called') clearAll() } void updated() { logTrace('updated called') logTrace("settings: ${settings}") logInfo("batteryPercentageReportTime setting is: ${PREFBATTERYREPORTTIME[batteryPercentageReportTime ?: '28800']}", true) logInfo("logging level is: ${PREFLOGGING[loggingOption]}", true) clearAll() if (logMatch('debug')) { logInfo("logging level will reduce to Driver Error Logging after ${LOGSOFF} seconds", true) runIn(LOGSOFF, logsOff) } logInfo('if you have changed any Device Configuration Options, make sure that you hit Configure above!', true) } void logsOff() { logTrace('logsOff called') if (DEBUG) { logDebug('DEBUG field variable is set, not disabling logging automatically!', true) } else { logInfo('automatically reducing logging level to Driver Error Logging', true) device.updateSetting('loggingOption', [value: '3', type: 'enum']) } } List configure() { logTrace('configure called') logDebug('battery powered device requires manual wakeup to accept configuration commands') logDebug("battery percentage time is: ${batteryPercentageReportTime.toBigDecimal()}") Integer batteryTime = batteryPercentageReportTime.toInteger() List cmds = ["zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0001 {${device.zigbeeId}} {}", "delay ${ZIGBEEDELAY}", "he cr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0001 0x0021 0x20 3600 ${batteryTime} {${intTo16bitUnsignedHex(2)}}", "delay ${ZIGBEEDELAY}", "he raw 0x${device.deviceNetworkId} 0x01 0x${device.endpointId} 0x0001 {10 00 08 00 2100}", "delay ${ZIGBEEDELAY}", "he rattr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0001 0x0021 {}", "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0400 {${device.zigbeeId}} {}", "delay ${ZIGBEEDELAY}", "he cr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0400 0x0000 0x21 0 65535 {0} {}", "delay ${ZIGBEEDELAY}", "he raw 0x${device.deviceNetworkId} 0x01 0x${device.endpointId} 0x0400 {10 00 08 00 0000}", "delay ${ZIGBEEDELAY}", "he rattr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0400 0x0000 {}"] logDebug("sending ${cmds}") return cmds } List> parse(String description) { logTrace('parse called') if (description) { logDebug("got description: ${description}") if (description.startsWith('enroll') || description.startsWith('zone')) { List> events = processIASEvents(description) if (events) { logDebug("parse returning events: ${events}") return events } logDebug("unhandled description: ${description}") } else { Map descriptionMap = null try { descriptionMap = zigbee.parseDescriptionAsMap(description) } catch (Exception ex) { logError("could not parse the description as platform threw error: ${ex}") } if (descriptionMap == [:]) { logWarn("descriptionMap is empty, can't continue!") } else if (descriptionMap) { List> events = processEvents(descriptionMap) if (events) { logDebug("parse returning events: ${events}") return events } logDebug("unhandled descriptionMap: ${descriptionMap}") } else { logWarn('no descriptionMap available!') } } } else { logWarn('empty description!') } } private List processIASEvents(String description, List events = []) { logDebug('processIASEvents called') logDebug("got description: ${description}") if (description.startsWith('zone status')) { hubitat.zigbee.clusters.iaszone.ZoneStatus zoneStatus = zigbee.parseZoneStatus(description) if (zoneStatus != null) { logDebug('got zoneStatus') logDebug("alarm1: ${zoneStatus.alarm1}") logDebug("alarm2: ${zoneStatus.alarm2}") logDebug("tamper: ${zoneStatus.tamper}") logDebug("battery: ${zoneStatus.battery}") logDebug("supervisionReports: ${zoneStatus.supervisionReports}") logDebug("restoreReports: ${zoneStatus.restoreReports}") logDebug("trouble: ${zoneStatus.trouble}") logDebug("ac: ${zoneStatus.ac}") logDebug("test: ${zoneStatus.test}") logDebug("batteryDefect: ${zoneStatus.batteryDefect}") Boolean alarmState = zoneStatus.alarm1 || zoneStatus.alarm2 logDebug("alarm state is ${alarmState}") String motionState = alarmState ? 'active' : 'inactive' String descriptionText = "${device.displayName} motion is ${motionState}" logEvent(descriptionText) events.add(processEvent([name: 'motion', value: motionState, descriptionText: descriptionText])) } else { logDebug('could not parse zoneStatus') } } else if (description.startsWith('enroll request')) { logDebug('got enrollRequest, not supported by device, ignoring') } return events } private List processEvents(Map descriptionMap, List events = []) { logTrace('processEvents called') logDebug("got descriptionMap: ${descriptionMap}") if (descriptionMap.profileId && descriptionMap.profileId == '0000') { logTrace('skipping ZDP profile message') } else if (!(descriptionMap.profileId) || (descriptionMap.profileId && descriptionMap.profileId == '0104')) { if (descriptionMap.cluster == '0001' || descriptionMap.clusterId == '0001' || descriptionMap.clusterInt == 1) { processPowerConfigurationCluster(descriptionMap, events) } else if (descriptionMap.cluster == '0400' || descriptionMap.clusterId == '0400' || descriptionMap.clusterInt == 1024) { processIlluminanceMeasurementCluster(descriptionMap, events) } else { logDebug("skipped descriptionMap.cluster: ${descriptionMap.cluster ?: 'unknown'} descriptionMap.clusterId: ${descriptionMap.clusterId ?: 'unknown'} descriptionMap.clusterInt: ${descriptionMap.clusterInt ?: 'unknown'}") } if (descriptionMap.additionalAttrs) { logDebug("got additionalAttrs: ${descriptionMap.additionalAttrs}") descriptionMap.additionalAttrs.each { Map attribute -> attribute.clusterInt = descriptionMap.clusterInt attribute.cluster = descriptionMap.cluster attribute.clusterId = descriptionMap.clusterId attribute.command = descriptionMap.command processEvents(attribute, events) } } } return events } private void processPowerConfigurationCluster(Map descriptionMap, List events) { logTrace('processPowerConfigurationCluster called') switch (descriptionMap.command) { case '0A': case '01': if (descriptionMap.attrId == '0021' || descriptionMap.attrInt == 33) { logDebug('power configuration (0001) battery percentage report (0021)') Integer batteryValue = zigbee.convertHexToInt(descriptionMap.value) logDebug("battery percentage report is ${batteryValue}") batteryValue = batteryValue.intdiv(2) logDebug("calculated battery percentage is ${batteryValue}") String descriptionText = "${device.displayName} battery percent is ${batteryValue}%" logEvent(descriptionText) events.add(processEvent([name: 'battery', value: batteryValue, unit: '%', descriptionText: descriptionText, isStateChange: true])) } else { logDebug('power configuration (0001) attribute skipped') } break case '04': logDebug('power configuration (0001) write attribute response (04) skipped') break case '07': logDebug('power configuration (0001) configure reporting response (07) skipped') break case '0B': logDebug('power configuration (0001) default response (0B) skipped') break default: logDebug('power configuration (0001) command skipped') break } } private void processIlluminanceMeasurementCluster(Map descriptionMap, List events) { logTrace('processIlluminanceMeasurementCluster called') switch (descriptionMap.command) { case '0A': case '01': if (descriptionMap.attrId == '0000' || descriptionMap.attrInt == 0) { logDebug('illuminance measurement (0400) measured value report (0000)') Integer illuminanceValue = zigbee.convertHexToInt(descriptionMap.value) logDebug("illuminance measurement measured value report is ${illuminanceValue}") illuminanceValue = Math.pow(10, (illuminanceValue / 10000)) + 1 logDebug("calculated illuminance is ${illuminanceValue}") if (illuminanceValue > 0 && illuminanceValue <= 2200) { illuminanceValue = -7.969192 + (0.0151988 * illuminanceValue) logDebug("illuminanceValue1 is ${illuminanceValue}") } else if (illuminanceValue > 2200 && illuminanceValue <= 2500) { illuminanceValue = -1069.189434 + (0.4950663 * illuminanceValue) logDebug("illuminanceValue2 is ${illuminanceValue}") } else if (illuminanceValue > 2500) { illuminanceValue = (78029.21628 - (61.73575 * illuminanceValue)) + (0.01223567 * (illuminanceValue ** 2)) logDebug("illuminanceValue2 is ${illuminanceValue}") } illuminanceValue = illuminanceValue < 1 ? 1 : illuminanceValue logDebug("calibrated illuminance is ${illuminanceValue}") String descriptionText = "${device.displayName} illuminance is ${illuminanceValue}lx" logEvent(descriptionText) events.add(processEvent([name: 'illuminance', value: illuminanceValue, unit: 'lx', descriptionText: descriptionText])) } else { logDebug('illuminance measurement (0400) attribute skipped') } break case '04': logDebug('illuminance measurement (0400) write attribute response (04) skipped') break case '07': logDebug('illuminance measurement (0400) configure reporting response (07) skipped') break case '0B': logDebug('illuminance measurement (0400) default response (0B) skipped') break default: logDebug('illuminance measurement (0400) command skipped') break } } private Map processEvent(Map event) { logTrace("processEvent called data: ${event}") return createEvent(event) } private Boolean logMatch(String logLevel) { Map logLevels = ['event': '0', 'info': '1', 'warn': '2', 'error': '3', 'debug': '4', 'trace': '5' ] return loggingOption ? loggingOption.toInteger() >= logLevels[logLevel].toInteger() : false } private String logTrace(String msg, Boolean override = false) { if (logMatch('trace') || override) { log.trace(logMsg(msg)) } } private String logDebug(String msg, Boolean override = false) { if (logMatch('debug') || override) { log.debug(logMsg(msg)) } } private String logError(String msg, Boolean override = false) { if (logMatch('error') || override) { log.error(logMsg(msg)) } } private String logWarn(String msg, Boolean override = false) { if (logMatch('warn') || override) { log.warn(logMsg(msg)) } } private String logInfo(String msg, Boolean override = false) { if (logMatch('info') || override) { log.info(logMsg(msg)) } } private String logEvent(String msg, Boolean override = false) { if (logMatch('event') || override) { log.info(logMsg(msg)) } } private String logMsg(String msg) { String log = "candeo logging for ${CANDEO} -- " log += msg return log } private void logsOn() { logTrace('logsOn called', true) device.updateSetting('loggingOption', [value: '5', type: 'enum']) runIn(LOGSOFF, logsOff) } private void clearAll() { logTrace('clearAll called') state.clear() atomicState.clear() unschedule() } private String intTo16bitUnsignedHex(Integer value) { String hexStr = zigbee.convertToHexString(value.toInteger(), 4) return new String(hexStr.substring(2, 4) + hexStr.substring(0, 2)) }