/* Copyright 2022 - tomw 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. ------------------------------------------- Change history: 0.9.10 - tomw - Added lirc code import helpers. 0.9.3 - tomw - Rework app UI to use buttons. 0.9.2 - tomw - Rename codes (supported in app). 0.9.0 - tomw - Initial release. */ library ( author: "tomw", category: "", description: "Broadlink helpers", name: "broadlinkHelpers", namespace: "tomw", documentationLink: "", importUrl: "https://raw.githubusercontent.com/tomwpublic/hubitat_broadlink/main/libraries/broadlinkHelpers" ) import groovy.transform.Field import hubitat.helper.HexUtils //////////////////////////// // saved codes operations //////////////////////////// @Field String codesSync = "" def deleteSavedCode(name) { def res synchronized(codesSync) { res = state.codes?.remove(name) } if(res) { log.info "removed code: ${name}" return true } else { log.info "code not found: ${name}" return false } } def addSavedCode(name, code) { if(null == state.codes) { clearSavedCodes() } Map codeEntry = [(name): code] logDebug("adding code: ${codeEntry}") synchronized(codesSync) { state.codes << codeEntry } return codeEntry } def getSavedCode(name) { codes = state.codes // try to get at user-specific name first, // then check against modified naming style used by old integration as a last resort code = codes?.get(name) ?: codes?.get(name?.tokenize(" ,!@#\$%^&()")?.join("_")) if(null == code) { log.info "code not found: ${name}" } return code } def renameSavedCode(oldName, newName) { if([oldName, newName].contains(null)) { return } def code = getSavedCode(oldName) if(!code) { return } if(deleteSavedCode(oldName)) { return addSavedCode(newName, code) } } def clearSavedCodes() { synchronized(codesSync) { state.codes = [:] } } def importCodes(String codeStore) { // use codeStore from previous integration, using "name1=code1,name2=code2" // Hubitat formatting in State Variables, with or without {} codeStore = codeStore?.replace('{','')?.replace('}','') // turn it into a map... codeStore?.split(',')?.each { def entry = it.split('=') if(entry.size() == 2) { // ...and import each one addSavedCode(entry[0].toString().trim(), entry[1].toString().trim()) } } } def allKnownCodesMap() { return state.codes ?: [:] } def allKnownCodesKeys() { return allKnownCodesMap()?.keySet()?.sort() } //////////////////////////// // import codes operations (pronto, lirc) //////////////////////////// String prepCodeStr(rawCode) { // build the full Broadlink code string from the raw IR sequence def strCode // length of raw conversion (no header, no leadout) def len = HexUtils.hexStringToByteArray(rawCode).size() len = HexUtils.byteArrayToHexString([0xFF & len, 0xFF & (len >> 8)] as byte[]) strCode = "2600" + len + rawCode // IR leadout (standard) strCode += HexUtils.byteArrayToHexString([0x0d, 0x05] as byte[]) return strCode } String encodeValToCode(code, mult) { // encode each code byte into the Broadlink scheme based on its value code = (code * mult).toInteger() byte[] tmpCode if(code <= 0xFF) { tmpCode = [code] } else { // leading zero, then big-endian if larger than one byte tmpCode = [0, 0xFF & (code >> 8), 0xFF & code] } return HexUtils.byteArrayToHexString(tmpCode) } def convertProntoToBroadlink(pronto) { def codes = parseProntoStringToCodes(pronto) if(!codes || (codes == [])) { logDebug("pronto error: invalid bytes") } if(validateProntoCodes(codes)) { return buildBroadlinkFromProntoCodes(codes) } } def buildBroadlinkFromProntoCodes(codes) { // http://www.remotecentral.com/features/irdisp2.htm // https://github.com/mjg59/python-broadlink/blob/master/protocol.md // freq in MHz def freq = 1 / (codes[1] * 0.241246) logDebug("freq = ${freq}") // here we are multiplying by the period of the specified signal def mult = (269 / 8192) / freq logDebug("pulse mult = ${mult}") def pairsCnt1 = codes[2] def pairsCnt2 = codes[3] def strCode = "" for(i = 4; i < 2*(pairsCnt1 + pairsCnt2) + 4; i++) { strCode += encodeValToCode(codes[i], mult) } logDebug("raw pronto conversion: ${strCode}") return prepCodeStr(strCode) } def parseProntoStringToCodes(pronto) { if(!pronto || (pronto == "")) { return } def code = pronto.replace(" ", "") byte[] bCode = HexUtils.hexStringToByteArray(code) def codes = [] for(i = 0; i < bCode.size(); i = i + 2) { // repack this into 2-byte codes codes += ((bCode[i] & 0xFF) << 8) | (bCode[i+1] & 0xFF) } return codes } def validateProntoCodes(codes) { if(0 != codes[0] || 0 == codes[1]) { // first word must be 0x0000 // second word is frequency and should be non-zero logDebug("pronto error: invalid preamble") return } def pairsCnt1 = codes[2] def pairsCnt2 = codes[3] if((codes.size() - 4) != (2 * (pairsCnt1 + pairsCnt2))) { // check remaining data against expected numbers of *pairs* logDebug("pronto error: invalid size") return } return [pairsCnt1: pairsCnt1, pairsCnt2: pairsCnt2] } def generateCodesFromLircBits(numBits, codeVal, lircSpec) { def strCode = "" // here we are multiplying by actual times (in us) def mult = (269 / 8192) for(int i = numBits - 1; i >= 0; i--) { if((codeVal >> i) & 0x01) { strCode += encodeValToCode(lircSpec.one.high, mult) strCode += encodeValToCode(lircSpec.one.low, mult) } else { strCode += encodeValToCode(lircSpec.zero.high, mult) strCode += encodeValToCode(lircSpec.zero.low, mult) } } return strCode } def buildBroadlinkFromLircCode(Map lircSpec) { if([lircSpec, lircSpec.code, lircSpec.bits].contains(null)) { logDebug("lirc error: invalid spec") return } def strCode = "" // here we are multiplying by actual times (in us) def mult = (269 / 8192) if(lircSpec.header) { // any header data, if present strCode += encodeValToCode(lircSpec.header.high, mult) strCode += encodeValToCode(lircSpec.header.low, mult) } if(lircSpec.pre_data && lircSpec.pre_data_bits) { // any pre_data, if present strCode += generateCodesFromLircBits(lircSpec.pre_data_bits, lircSpec.pre_data, lircSpec) } // the actual code strCode += generateCodesFromLircBits(lircSpec.bits, lircSpec.code, lircSpec) if(lircSpec.post_data && lircSpec.post_data_bits) { // any post_data, if present strCode += generateCodesFromLircBits(lircSpec.post_data_bits, lircSpec.post_data, lircSpec) } if(lircSpec.ptrail) { // any ptrail, if present strCode += encodeValToCode(lircSpec.ptrail, mult) // low value for ptrail is never(?) present, so just use the low value for a one strCode += encodeValToCode(lircSpec.one.low, mult) } logDebug("raw lirc conversion: ${strCode}") return prepCodeStr(strCode) } private _lircGetIntValsByName(input, name, countOfVals) { def splitInput = input?.split() def idxName = splitInput.findIndexOf { it.toLowerCase() == name } if(-1 == idxName) { return } List retList = [] for(int i = idxName + 1; i <= (idxName + countOfVals); i++) { thisVal = splitInput[i] thisVal = thisVal.startsWith("0x") ? _turn0xStringIntoInt(thisVal) : thisVal retList += thisVal?.toInteger() } return retList } private _lircGetStringValByName(input, name) { def splitInput = input?.split() def idxName = splitInput.findIndexOf { it.toLowerCase() == name } if(-1 == idxName) { return } return splitInput[idxName + 1] } def _turn0xStringIntoInt(hexString) { if(!hexString) { return } // TODO: this will probably break for codes larger than 32 bits? return new BigInteger(hexString?.replace("0x", ""), 16)?.toInteger() } def lircParseFile(input) { // remove comment lines and empty lines (into cleanedInput) def cleanedInput = "" input?.eachLine { line -> if(line && !line?.trim()?.startsWith('#')) { cleanedInput += (line + "\n") } } logDebug("cleanedInput file follows:") logDebug(cleanedInput) input = cleanedInput // first, extract the basic lirc details... def one = _lircGetIntValsByName(input, "one", 2) def zero = _lircGetIntValsByName(input, "zero", 2) def header = _lircGetIntValsByName(input, "header", 2) // header may not be in source file, so process it here first if(header) { header = [high: header[0], low: header[1]] } def lircSpec = [ name: _lircGetStringValByName(input, "name"), bits: _lircGetIntValsByName(input, "bits", 1)?.get(0), header: header, one: [high: one[0], low: one[1]], zero: [high: zero[0], low: zero[1]], pre_data: _lircGetIntValsByName(input, "pre_data", 1)?.get(0), pre_data_bits: _lircGetIntValsByName(input, "pre_data_bits", 1)?.get(0), post_data: _lircGetIntValsByName(input, "post_data", 1)?.get(0), post_data_bits: _lircGetIntValsByName(input, "post_data_bits", 1)?.get(0), ptrail: _lircGetIntValsByName(input, "ptrail", 1)?.get(0) ] // Get the text between 'begin codes' and 'end codes' // Note: this will break if there is more than one such section in a lirc file def codesInput = input.split('begin codes')?.getAt(1)?.split('end codes') logDebug("codesInput section follows:") logDebug(codesInput) // ...then, extract each code for processing def codeSpecs = [] codesInput?.getAt(0)?.eachLine { line -> line = line.split() if(line.size() >= 2) { // prepend remote name to code name, if present codeSpecs += [name: (lircSpec.name ? "${lircSpec.name}_" : "") + line[0], code: _turn0xStringIntoInt(line[1])] } } def finalSpec = [lircSpec: lircSpec, codeSpecs: codeSpecs] logDebug("finalSpec follows:") logDebug(finalSpec) return finalSpec } ////////////////////////////////////// // volatile state ////////////////////////////////////// @Field static volatileState = [:].asSynchronized() def vsUid() { return device?.getDeviceNetworkId() ?: app.getId() } def setVolatileState(name, value) { def tempState = volatileState[vsUid()] ?: [:] tempState.putAt(name, value) volatileState.putAt(vsUid(), tempState) return volatileState } def getVolatileState(name) { return volatileState.getAt(vsUid())?.getAt(name) }