-- a-lurker, copyright 2017, 2018, 2019 & 2020 -- First release 10 December 2017; updated 1 May 2020 -- Tested on openLuup --[[ This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License version 3 (GPLv3) as published by the Free Software Foundation; In addition to the GPLv3 License, this software is only for private or home usage. Commercial utilisation is not authorized. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. ]] --[[ Typically the BroadLink device is already paired with the phone app. The following is just further information on pairing in general. On purchase the BroadLink device is in a mode where it can be configured via a mobile phone. We need to change the mode to AP mode. Place the BroadLink device into AP mode by holding down the reset button about four seconds. A successful change to AP mode is indicated by the blue LED: four slow flashes followed by a one second pause. At this point the device acts as WiFi access point (AP). It runs a DCHP server on 192.168.10.1 Any Vera, PC, etc, that connects to the AP, will be given an address of 192.168.10.2, which will increment, as further devices are connected to the AP. We need to send a pairing message to the AP on 92.168.10.1 In this pairing message, we send the WiFi SSID and password of the AP that is part of our Vera network. Once successfully received, the BroadLink device with disable its own AP and DCHP server and connect to the AP specified in our message ie the LAN connected to Vera. The blue LED goes completely off. The BroadLink device effectively stops acting as an AP and changes to being a slave device. At this point we can start to use the facilities the BroadLink device offers. Refer to: sendPairingMsg(), which works but is not called by this code. Devices use broadcast and multicast on 224.0.0.251 Refs: https://github.com/mjg59/python-broadlink https://github.com/lprhodes/broadlinkjs-rm/blob/master/index.js https://blog.ipsumdomus.com/broadlink-smart-home-devices-complete-protocol-hack-bc0b4b397af1 https://github.com/sayzard/BroadLinkESP/blob/master/BroadLinkESP.cpp https://github.com/mob41/broadlink-java-api/tree/master/src/main/java/com/github/mob41/blapi ]] local PLUGIN_NAME = 'BroadLink_Mk2' local PLUGIN_SID = 'urn:a-lurker-com:serviceId:'..PLUGIN_NAME..'_1' local PLUGIN_VERSION = '0.55' local THIS_LUL_DEVICE = nil -- your WiFi SSID and PASS. Only required if not using the phone -- app to pair the BroadLink device. Refer to: sendPairingMsg() local SSID = 'my_SID' local PASS = 'my_PASS' local DEV = { BINARY_LIGHT = 'urn:schemas-upnp-org:device:BinaryLight:1', -- also energy metering DOOR_SENSOR = 'urn:schemas-micasaverde-com:device:DoorSensor:1', -- security sensor GENERIC_SENSOR = 'urn:schemas-micasaverde-com:device:GenericSensor:1', HUMIDITY_SENSOR = 'urn:schemas-micasaverde-com:device:HumiditySensor:1', IR_TRANSMITTER = 'urn:schemas-micasaverde-com:device:IrTransmitter:1', LIGHT_SENSOR = 'urn:schemas-micasaverde-com:device:LightSensor:1', MOTION_SENSOR = 'urn:schemas-micasaverde-com:device:MotionSensor:1', -- security sensor SMOKE_SENSOR = 'urn:schemas-micasaverde-com:device:SmokeSensor:1', -- security sensor TEMPERATURE_SENSOR = 'urn:schemas-micasaverde-com:device:TemperatureSensor:1' } local FILE = { BINARY_LIGHT = 'D_BinaryLight1.xml', DOOR_SENSOR = 'D_DoorSensor1.xml', GENERIC_SENSOR = 'D_GenericSensor1.xml', HUMIDITY_SENSOR = 'D_HumiditySensor1.xml', IR_TRANSMITTER = 'D_BroadLink_Mk2_IrRf_1.xml', -- overrides: 'D_IrTransmitter1.xml' LIGHT_SENSOR = 'D_LightSensor1.xml', MOTION_SENSOR = 'D_MotionSensor1.xml', SMOKE_SENSOR = 'D_SmokeSensor1.xml', TEMPERATURE_SENSOR = 'D_TemperatureSensor1.xml' } local SID = { BINARY_LIGHT = 'urn:upnp-org:serviceId:SwitchPower1', DOOR_SENSOR = 'urn:micasaverde-com:serviceId:SecuritySensor1', ENERGY_METERING = 'urn:micasaverde-com:serviceId:EnergyMetering1', -- see FILE.BINARY_LIGHT GENERIC_SENSOR = 'urn:micasaverde-com:serviceId:GenericSensor1', HA = 'urn:micasaverde-com:serviceId:HaDevice1', HUMIDITY_SENSOR = 'urn:micasaverde-com:serviceId:HumiditySensor1', IR_TRANSMITTER = 'urn:a-lurker-com:serviceId:IrTransmitter1', -- 'urn:micasaverde-com:serviceId:IrTransmitter1' LIGHT_SENSOR = 'urn:micasaverde-com:serviceId:LightSensor1', MOTION_SENSOR = 'urn:micasaverde-com:serviceId:SecuritySensor1', SMOKE_SENSOR = 'urn:micasaverde-com:serviceId:SecuritySensor1', TEMPERATURE_SENSOR = 'urn:upnp-org:serviceId:TemperatureSensor1' } local BROADLINK_AP_IP = '192.168.10.1' local UDP_IP_PORT = 80 local OUR_IP = '' local MSG_TIMEOUT = 1 local CHECKSUM_SEED = 0xbeaf local FIVE_MIN_IN_SECS = 300 local m_PollInterval = FIVE_MIN_IN_SECS local m_PollEnable = '' -- is set to either: '0' or '1' local m_PollLastState = '' local m_msgCount = -1 local m_doEncodeDecode = true -- used for testing purposes only local m_json = nil local m_IRScanCount = 0 local m_RFScanCount = 0 local RF = { START = 1, GET_FREQ = 2, GET_CODE = 3, DONE = 4, ABORT_1 = 5, ABORT_2 = 6 } local m_RfScanningState = RF.START -- AES-128 CBC algorithm with no padding local initialKey = '097628343fe99e23765c1513accf8b02' -- 0x09, 0x76, 0x28, 0x34, 0x3f, 0xe9, 0x9e, 0x23, 0x76, 0x5c, 0x15, 0x13, 0xac, 0xcf, 0x8b, 0x02 -- Note: IVs should not be reused! local initialVector = '562e17996d093d28ddb3ba695a2e6f58' -- 0x56, 0x2e, 0x17, 0x99, 0x6d, 0x09, 0x3d, 0x28, 0xdd, 0xb3, 0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58 -- SO WHAT'S WITH ALL THE PLUS ONES???? - it was easier to follow the reference information that way - that's why! -- So most hex numbers shown start with a C style zero base and have one added on produce a one as the starting point for Lua. -- It's also nice to use Lua's ipairs and for 'length = #table' to give the correct answer! -- checksum bytes local idxChkSum = {msb = 0x21+1, lsb = 0x20+1} -- error msg bytes local idxError = {msb = 0x23+1, lsb = 0x22+1} -- device id bytes local idxDeviceId = {msb = 0x25+1, lsb = 0x24+1} -- msg count bytes local idxCount = {msb = 0x29+1, lsb = 0x28+1} -- used to match incoming responses to outgoing messages -- payload checksum bytes local idxPayloadChkSum = {msb = 0x35+1, lsb = 0x34+1} -- command and response codes local blCmds = { discoverAPs = {tx = 0x1a, rx = 0x1b}, pairing = {tx = 0x14, rx = 0x15}, discoverDevices = {tx = 0x06, rx = 0x07}, auth = {tx = 0x65, rx = 0xe9}, -- 0x3e9, we'll ignore the msb sp1 = {tx = 0x66, rx = 0xe9}, -- no idea if 0x3e9 is returned or not???? HACK readWrite = {tx = 0x6a, rx = 0xee} -- 0x3ee, we'll ignore the msb } -- payload commands are placed at the 1st byte (0x00) of the payload (well - seem to be) local plCmds = { off = 0x00, -- cmd = 0x6a, 4 byte payload on = 0x01, -- cmd = 0x6a, 4 byte payload get = 0x01, -- cmd = 0x6a, 16 byte payload set = 0x02, -- cmd = 0x6a, 4 byte + payload size ie off, on, ir/rf data: location 0x05 = 0x26 for ir data, else rf data irLearnStart = 0x03, -- cmd = 0x6a, 16 byte payload irGetCode = 0x04, -- cmd = 0x6a, 16 byte payload sensorsGet = 0x06, -- cmd = 0x6a, 16 byte payload sensorsSet = 0x07, -- cmd = 0x6a, 16 byte payload energyGet = 0x08, -- cmd = 0x6a, 16 byte payload dooya = 0x09, -- cmd = 0x6a, 16 byte payload mp1RlyStatus = 0x0a, -- cmd = 0x6a, 16 byte payload mp1RlySw = 0x0d, -- cmd = 0x6a, 16 byte payload rfLearnStart = 0x19, -- cmd = 0x6a, 16 byte payload rfLearnFreq = 0x1a, -- cmd = 0x6a, 16 byte payload rfLearnCode = 0x1b, -- cmd = 0x6a, 16 byte payload rfLearnStop = 0x1e -- cmd = 0x6a, 16 byte payload } -- returned data typically starts at the 5th byte (0x04+1) of the payload local plData = { status = 0x04+1, irCodeIdx0 = 0x04+1, rfCodeIdx0 = 0x04+1, rfFoundFlag = 0x04+1, energy = {msb = 0x07+1, isb = 0x06+1, lsb = 0x05+1}, temperature = {msb = 0x04+1, lsb = 0x05+1}, humidity = {msb = 0x06+1, lsb = 0x07+1}, lightLevel = 0x08+1, airQuality = 0x0a+1, noiseLevel = 0x0c+1 } --[[ This look up table describes the capabilities of a physical Broadlink device and has ptrs to the associated functions. An example element in the blDevs table is shown below. The index is the internal hex number that represents each type of Broadlink device. In the example 0x2787 is the value for a 'RM2 Pro Plus 2' blDeviceType = 0x2787 blDevs[blDeviceType].desc = 'RM2 Pro Plus 2' blDevs[blDeviceType].devs.ir = ctrlrRf -- ptr to ctrlrRf function blDevs[blDeviceType].devs.temp = getTemperature -- ptr to getTemperature function blDevs[blDeviceType].devs.rf315 = ctrlrRf -- ptr to ctrlrRf function blDevs[blDeviceType].devs.rf433 = ctrlrRf -- ptr to ctrlrRf function blDevs[blDeviceType].plHdrs = {0x0004, 0x000da} -- payload protocol headers (when applicable) ]] local blDevs = {} --[[ blDevices[blId] = { -- blId, string, the id of a BroadLink physical device: we'll use the BroadLink device's mac address -- the following is derived from broadcasted discovery process blIp = ip, -- string: ip address of the host BroadLink device blDeviceType = blDeviceType, -- number: id of the host BroadLink device 'type' blDesc = blDevs[blDeviceType].desc, -- string: description of the host BroadLink device eg "RM pro", etc -- the following is derived from authorisation process blInternalId = internalId, -- string: the id returned from the BroadLink host device during the authorisation process blKey = key -- string: the key returned from the BroadLink host device during the authorisation process } ]] local blDevices = {} --[[ All the Vera devices eg: temperature sensors, relays, etc and in which physical BroadLink device they are located veraDevices[altId] = { -- altId, as used by the this vera plugin, of the form: host mac address plus the vera function type blId = blId, -- the id of the BroadLink parent device - which is simply its mac address veraDesc = veraDesc, -- vera device description, as seen in the user interface veraDevice = dev, -- vera device type - for child creation veraFile = file, -- vera device file - for child creation veraFunc = func, -- vera device function veraId = lul_device.id -- vera device's id } ]] local veraDevices = {} -- http://w3.impa.br/~diego/software/luasocket/reference.html local socket = require('socket') local SHOW_AES = false -- don't change this, it won't do anything. Use the debugEnabled flag instead local DEBUG_MODE = true local function debug(textParm, logLevel) if DEBUG_MODE then local text = '' local theType = type(textParm) if (theType == 'string') then text = textParm else text = 'type = '..theType..', value = '..tostring(textParm) end luup.log(PLUGIN_NAME..' debug: '..text,50) elseif (logLevel) then local text = '' if (type(textParm) == 'string') then text = textParm end luup.log(PLUGIN_NAME..' debug: '..text, logLevel) end end -- If non existent, create the variable. Update -- the variable, only if it needs to be updated local function updateVariable(varK, varV, sid, id) if (sid == nil) then sid = PLUGIN_SID end if (id == nil) then id = THIS_LUL_DEVICE end if ((varK == nil) or (varV == nil)) then luup.log(PLUGIN_NAME..' debug: '..'Error: updateVariable was supplied with a nil value', 1) return end local newValue = tostring(varV) --debug(varK..' = '..newValue) debug(newValue..' --> '..varK) local currentValue = luup.variable_get(sid, varK, id) if ((currentValue ~= newValue) or (currentValue == nil)) then luup.variable_set(sid, varK, newValue, id) end end -- If possible, get a JSON parser. If none available, returns nil. Note that typically UI5 may not have a parser available. local function loadJsonModule() local jsonModules = { 'dkjson', -- UI7 firmware 'openLuup.json', -- https://community.getvera.com/t/pure-lua-json-library-akb-json/185273 'akb-json', -- https://community.getvera.com/t/pure-lua-json-library-akb-json/185273 'json', -- OWServer plugin 'json-dm2', -- dataMine plugin 'dropbox_json_parser', -- dropbox plugin 'hue_json', -- hue plugin 'L_ALTUIjson' -- AltUI plugin } local ptr = nil local json = nil for n = 1, #jsonModules do -- require does not load the module, if it's already loaded -- Vera has overloaded require to suit their requirements, so it works differently from openLuup -- openLuup: -- ok: returns true or false indicating if the module was loaded successfully or not -- result: contains the ptr to the module or an error string showing the path(s) searched for the module -- Vera: -- ok: returns true or false indicating the require function executed but require may have or may not have loaded the module -- result: contains the ptr to the module or an error string showing the path(s) searched for the module -- log: log reports 'luup_require can't find xyz.json' local ok, result = pcall(require, jsonModules[n]) ptr = package.loaded[jsonModules[n]] if (ptr) then json = ptr debug('Using: '..jsonModules[n]) break end end if (not json) then debug('No JSON library found') return json end return json end -- Log the outcome (hex) - only used for testing local function tableDump(userMsg, byteTab) if (not DEBUG_MODE) then return end if (byteTab == nil) then debug(userMsg..' is nil') return end local tabLen = #byteTab local hex = '' local asc = '' local hexTab = {} local ascTab = {' '} local dmpTab = {userMsg..'\n\n'} for i=1, tabLen do local ord = byteTab[i] hex = string.format("%02X", ord) asc = '.' if ((ord >= 32) and (ord <= 126)) then asc = string.char(ord) end table.insert(hexTab, hex) table.insert(ascTab, asc) if ((i % 16 == 0) or (i == tabLen))then table.insert(ascTab,'\n') table.insert(dmpTab,table.concat(hexTab, ' ')) table.insert(dmpTab,table.concat(ascTab)) hexTab = {} ascTab = {' '} elseif (i % 8 == 0) then table.insert(hexTab, '') table.insert(ascTab, '') end end debug(table.concat(dmpTab)) end -- Compute the difference in seconds between local time and UTC including daylight saving local function get_timezone_offset() local ts = os.time() local utcdate = os.date('!*t', ts) local localdate = os.date('*t', ts) localdate.isdst = false -- this is the trick return os.difftime(os.time(localdate), os.time(utcdate)), localdate end -- Split a word over two bytes and insert it as specified local function insertMsbLsb(msgTab, location, var) local msb = math.floor(var/0x100) msgTab[location.lsb] = var - (msb * 0x100) msgTab[location.msb] = msb end -- The whole message is checksumed. This is done after everything else, including encryption local function insertChecksum(msgTab) local checksum = CHECKSUM_SEED for i = 1, #msgTab do checksum = checksum + msgTab[i] end local overflow = math.floor(checksum / 0x10000) checksum = checksum - (overflow * 0x10000) insertMsbLsb(msgTab, idxChkSum, checksum) end -- Overall checksum ok? local function validChecksum(rxMsgTab) -- get the checksum in the returned message local msgCheckum = rxMsgTab[idxChkSum.msb]*256 + rxMsgTab[idxChkSum.lsb] -- zero it out before rechecking rxMsgTab[idxChkSum.msb], rxMsgTab[idxChkSum.lsb] = 0x00, 0x00 local checksum = CHECKSUM_SEED for i = 1, #rxMsgTab do checksum = checksum + rxMsgTab[i] end local overflow = math.floor(checksum / 0x10000) checksum = checksum - (overflow * 0x10000) return msgCheckum == checksum end -- Payloads have their own checksum, which is calculated and inserted in the header before the payload is encrypted local function insertPayloadChecksum(headerTab, payloadTab) local checksum = CHECKSUM_SEED for i = 1, #payloadTab do checksum = checksum + payloadTab[i] end local overflow = math.floor(checksum / 0x10000) checksum = checksum - (overflow * 0x10000) insertMsbLsb(headerTab, idxPayloadChkSum, checksum) end -- Payload checksum ok? local function validPayloadChecksum(rxMsgTab, payloadTab) -- get the checksum in the returned message local payloadCheckum = rxMsgTab[idxPayloadChkSum.msb]*256 + rxMsgTab[idxPayloadChkSum.lsb] local checksum = CHECKSUM_SEED for i = 1, #payloadTab do checksum = checksum + payloadTab[i] end local overflow = math.floor(checksum / 0x10000) checksum = checksum - (overflow * 0x10000) return payloadCheckum == checksum end -- Insert payload header for devices that require it local function insertPayloadHeader(payloadTab, type, blId) local plHdrs = blDevs[blDevices[blId].blDeviceType].plHdrs if plHdrs == nil then return end for i=#payloadTab, 1, -1 do payloadTab[i+2] = payloadTab[i] end insertMsbLsb(payloadTab, {msb = 0x00+2, lsb = 0x00+1}, plHdrs[type]) end -- Remove payload header for devices that require it local function removePayloadHeader(payloadTab, blId) local hdrLen = blDevs[blDevices[blId].blDeviceType].plHdrs and 2 or 0 for i=hdrLen+1, #payloadTab do payloadTab[i] = payloadTab[i+hdrLen] end end -- Responses to tx'ed messages return this count, so the replies to tx'ed messages can be matched together local function insertMsgCount(msgTab) m_msgCount = m_msgCount+1 -- Warning: if m_msgCount >= 0x7ffe at this point then things stop working eg IR stops transmitting. -- Presumably this is due to numbers in the host device wrapping around to negative values. -- We'll just limit the count range from 0 to 4095 to keep it simple if (m_msgCount >= 0x1000) then m_msgCount = 0 end insertMsbLsb(msgTab, idxCount, m_msgCount) end -- Table creation for messages local function makeEmptyTable(length) local zeroTab = {} -- set the table to all nulls for i = 1, length do zeroTab[i] = 0x00 end return zeroTab end -- Magically gets our vera's local ip address local function getOurIPaddress() local SOME_RANDOM_IP = '1.1.1.1' local SOME_RANDOM_PORT = '1' local udp = socket.udp() if (udp == nil) then debug('Socket failure: socket lib missing?',50) return '' end udp:setpeername(SOME_RANDOM_IP, SOME_RANDOM_PORT) -- now we can get our LAN IP address local ipa,_,_ = udp:getsockname() udp:close() return ipa end -- AES encryption requires the message to be multiples of 16 bytes ie 128 bits. Pad with zeroes as needed. local function padForAES(padThisTable) local remainder = #padThisTable % 16 -- 0 to 15 if (remainder ~= 0) then for i = 15, remainder, -1 do table.insert(padThisTable, 0x00) end end end -- Do the AES encrypt or decrypt on the payload only. -- If the input is a string then the output is a string. -- If the input is a table of bytes then the output is a table of bytes. local function encryptDecrypt(key, input, encrypt) if ((not key) or (key:len() ~= 32)) then debug ('AES key missing or incorrect size') return nil end local inputStr = input if (type(input) == 'table') then local strTab = {} -- table.concat does coercion of numbers which we don't want -- effectively here, we are setting all the elements to type char for i=1, #input do table.insert(strTab, string.char(input[i])) end inputStr = table.concat(strTab) end if (DEBUG_MODE and SHOW_AES and encrypt) then debug ('AES inputStr length = '..tostring(inputStr:len())) local inputHexTab = {} for c in inputStr:gmatch('.') do table.insert(inputHexTab, string.format('%02x', c:byte())) end debug(table.concat(inputHexTab, ' ')) end -- Tried using echo to pipe into openssl but got into too much -- trouble with escaping. So we'll use a file as input instead. local inputFile = io.open('/tmp/BroadLink.in', 'wb+') if (inputFile) then inputFile:write(inputStr) inputFile:close() end -- encrypting or decrypting? local inOut = '-d' if (encrypt) then inOut = '-e' end -- https://www.openssl.org/docs/manmaster/man1/enc.html local encDecCmdTab = { 'openssl', 'enc', -- use a symmetric cipher '-aes-128-cbc', -- Cipher Block Chaining (CBC) mode for AES assumes data in blocks of 16 bytes inOut, -- encrypt/decrypt '-nopad', -- we will do the padding. Default uses https://en.wikipedia.org/wiki/Padding_%28cryptography%29#PKCS7 '-in /tmp/BroadLink.in', -- don't use an output file; we'll capture the stdout data instead '-K', key, -- string of hex digits '-iv', initialVector -- string of hex digits } local encDecCmd = table.concat(encDecCmdTab,' ') if (SHOW_AES) then debug (encDecCmd) end -- capture the stdout data local pipeOut = assert(io.popen(encDecCmd, 'r')) local outputStr = assert(pipeOut:read('*a')) pipeOut:close() if (DEBUG_MODE and SHOW_AES and not encrypt) then debug ('AES outputStr length = '..tostring(outputStr:len())) local outputHexTab = {} for c in outputStr:gmatch('.') do table.insert(outputHexTab, string.format('%02x', c:byte())) end debug(table.concat(outputHexTab, ' ')) end if (type(input) == 'string') then return outputStr end local outputTab = {} for c in outputStr:gmatch('.') do table.insert(outputTab, string.byte(c)) end return outputTab end -- for testing only local function testAES() local inputTab = {0x5c, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x30, 0x31, 0x32, 0x33, 0x34, 0x00, 0xff, 0x00, 0x00} local encodeDecode = encryptDecrypt(initialKey, inputTab, true) local outputStr = encryptDecrypt(initialKey, encodeDecode, false) end -- Convert a Pronto IR code to a Broadlink IR code -- accepts a string in -- returns a byte table local function prontoCode2blCode(pCode) --[[ Pronto IR format: http://www.majority.nl/files/prontoirformats.pdf http://www.remotecentral.com/features/irdisp2.htm https://community.getvera.com/t/gc100-pronto-code/191951/10 BroadLink IR format: https://github.com/mjg59/python-broadlink/issues/57 BLCode[0x00] = 0x26 = IR, 0xd7 for RF 315MHz, 0xb2 for RF 433MHz BLCode[0x01] = repeat count, 0x0 no repeat, 0x1 repeat twice, etc BLCode[0x02] = lsb length of the following data including the terminator in bytes BLCode[0x03] = msb length of the following data including the terminator in bytes BLCode[0x04] = length of the mark or the space, starting with a mark, in 32836.9140625 Hz periods BLCode[last] = 0x0d 0x05 at the end for IR only (terminator) Notes: the lengths are a single byte, if < 256, else they are 3 bytes as: '0x00, msb, lsb' the hex codes can be upper or lower case ]] local ir = { method = 0x01, freqDiv = 0x02, onceSeqCnt = 0x03, repeatSeqCnt = 0x04, burstStart = 0x05 } local pCodeTab = {} for pc in pCode:gmatch('%x+') do table.insert(pCodeTab, tonumber(pc,16)) end -- we'll only do "raw" Prontos with no "once" sequence if ((pCodeTab[ir.method] ~= 0) and (pCodeTab[ir.onceSeqCnt] ~= 0)) then debug('Only raw Pronto Codes with no "once" sequence allowed',50) return {} end local PRONTO_PWM_HZ = 4145152 -- a constant measured in Hz and is the pulse width modulator frequency used by the Philip's Pronto remotes -- blFreqHz: possibly a watch xtal frequency at 32,768 Hz ???? local pcFreqHz = PRONTO_PWM_HZ / pCodeTab[ir.freqDiv] local blFreqHz = 32836.9140625 -- =(269/8192)*1e6 and (268/8192)*1e6=32714.84375 local freqRatio = blFreqHz/pcFreqHz local burstPairCnt = pCodeTab[ir.repeatSeqCnt] local irCodeTab = { 0x26, -- IR code flag, not 315/433 MHz RF 0x00, -- no repeats 0x00, -- lsb byte count excluding lead in (little endian) 0x00 -- msb byte count excluding lead in } local byteCnt = 0 for i = ir.burstStart, #pCodeTab do local numPeriods = math.floor((pCodeTab[i] * freqRatio) + 0.5) if (numPeriods > 256) then local msb = math.floor(numPeriods / 0x100) local lsb = numPeriods - (msb * 0x100) table.insert(irCodeTab, 0x00) -- dual byte starting flag table.insert(irCodeTab, msb) -- big endian table.insert(irCodeTab, lsb) -- big endian byteCnt = byteCnt+3 else table.insert(irCodeTab, numPeriods) byteCnt = byteCnt+1 end end -- append the lead out table.insert(irCodeTab, 0x0d) table.insert(irCodeTab, 0x05) byteCnt = byteCnt+2 -- the lead out is included in the count local msbBC = math.floor(byteCnt / 0x100) local lsbBC = byteCnt - (msbBC * 0x100) irCodeTab[0x03] = lsbBC -- little endian irCodeTab[0x04] = msbBC -- little endian -- for debugging purposes only if (DEBUG_MODE) then local irCodeTabHex = {} for _,v in ipairs(irCodeTab) do table.insert(irCodeTabHex, string.format('%02x', v)) end debug('Broadlink IR code 1 = '..table.concat(irCodeTabHex,' ')) end return irCodeTab end -- Add each physical BroadLink device to the list of BroadLink devices local function addToBroadlinkPhysicalDevicesList(rxMsg, ip) --[[ The response contains the 48 bytes we sent above, with updated checksum, plus another 80 bytes of info, making a total of 128 bytes. 0x26+1 = 0x7 reply to "discoverDevices" request blCmds.discoverDevices.rx 0x35+1 to 0x34+1 = device type eg as 2 bytes indicating "RM2 Pro Plus 2" etc. This determines capabilities. 0x39+1 to 0x36+1 = ip address NOTE: some devices eg RM PRO stores this little-endian; others eg SC1 relay stores this big-endian 0x3f+1 to 0x3a+1 = MAC address 0x40+1 to 0x4b+1 = a string of varying length in UTF8 For example: e699bae883bde981a5e68ea7 = Chinese for “intelligent remote control” plus other unknown stuff ]] -- ignore local loopback if (ip == OUR_IP) then return end -- convert the rx'ed string to a table of bytes local rxMsgTab = {} for c in rxMsg:gmatch('.') do table.insert(rxMsgTab, string.byte(c)) end local rxMsgLen = #rxMsgTab tableDump('Rx\'ed a discovery response: rxMsg length = '..tostring(rxMsgLen), rxMsgTab) -- sanity checks - sometimes the "Android RM bridge" or a router will trigger the first check here if (rxMsgLen ~= 128) then debug('Error: discovery msg - incorrect size: '..ip,50) return end if (rxMsgTab[0x26+1] ~= blCmds.discoverDevices.rx) then debug('Error: discovery msg - reply id incorrect',50) return end -- get the mac address contained in the returned message local strTab = {} for i = 0x3f+1, 0x3a+1, -1 do table.insert(strTab, string.format('%02x',rxMsgTab[i])) end -- get the mac address and use it as part of a BroadLink device blId and the Vera device altId -- indices are case sensitive, so force to lower just in case local mac = string.lower(table.concat(strTab,':')) local blId = mac -- get the hex number that represents this particular BroadLink device local blDeviceType = rxMsgTab[0x35+1]*256 + rxMsgTab[0x34+1] if (not blDevs[blDeviceType]) then debug(string.format('The BroadLink device at IP address %s and of type 0x%04x is not known to this plugin', ip, blDeviceType)) return end blDevices[blId] = { blIp = ip, blDeviceType = blDeviceType, blDesc = blDevs[blDeviceType].desc, -- the following will be filled in during the authorisation process blInternalId = '????', blKey = initialKey } debug(blId) -- the BroadLink device mac address debug(blDevices[blId].blIp) debug(string.format('BroadLink device type: 0x%04x', blDevices[blId].blDeviceType)) debug(blDevices[blId].blDesc) debug(blDevices[blId].blInternalId) debug(blDevices[blId].blKey) end -- Make the BroadLink "payload" header local function makeCmdHeader(blId, payloadTab, command) local headerTab = makeEmptyTable(0x38) -- 56 bytes long headerTab[0x00+1] = 0x5a -- private header -- CID = 24113000182295205 = 00 55 aa a5 5a 55 aa a5 = 8 bytes headerTab[0x01+1] = 0xa5 -- lsb connection id headerTab[0x02+1] = 0xaa headerTab[0x03+1] = 0x55 headerTab[0x04+1] = 0x5a headerTab[0x05+1] = 0xa5 headerTab[0x06+1] = 0xaa headerTab[0x07+1] = 0x55 -- msb-1 connection id --headerTab[0x08+1] = 0x00 -- msb connection id (is already set to 0x00) -- insert the id of the host BroadLink device 'type' into the header: 0x25 to 0x24 insertMsbLsb(headerTab, idxDeviceId, blDevices[blId].blDeviceType) headerTab[0x26+1] = command -- command is typically 0x65, 0x66 or 0x6a -- insert the counter into the header: 0x29,0x28 insertMsgCount(headerTab) -- the auth message gets the blInternalId, so when doing auth we skip this part local charIdx = 0x33+1 if (command ~= blCmds.auth.tx) then for c in blDevices[blId].blInternalId:gmatch('%x%x') do headerTab[charIdx] = tonumber(c,16) charIdx = charIdx-1 end end -- insert the mac address into the header: 0x2f to 0x2a charIdx = 0x2f+1 for c in blId:gmatch('%x%x') do headerTab[charIdx] = tonumber(c,16) charIdx = charIdx-1 end -- the payload checksum is placed in the main header -- at 0x35, 0x34 before the payload is encrypted insertPayloadChecksum(headerTab, payloadTab) return headerTab end -- Combine the header and the payload local function headerAndPayload(blId, payloadTab, command) -- only the payload is encrypted -- padding is added as needed, which is not that often; however -- the variable length IR messages certainly require it padForAES(payloadTab) -- we pass in the payloadTab, so the payload's own checksum can be calculated and inserted into the main header local msgTab = makeCmdHeader(blId, payloadTab, command) tableDump('Header to be sent follows (ex checksum):', msgTab) tableDump('Payload to be sent follows (unencrypted):', payloadTab) local encodedTab = encryptDecrypt(blDevices[blId].blKey, payloadTab, true) -- append the AES encoded payload table to the header table for _,v in ipairs(encodedTab) do table.insert(msgTab, v) end -- always gets done last insertChecksum(msgTab) return msgTab end -- Make the BroadLink "discover APs" message -- NOTE: could not get this to work! So code is not called. local function makeDiscoverAPsMsg() -- 8 byte QUIC header + 40 byte payload = 48dec = 0x30 local msgTab = makeEmptyTable(0x30) msgTab[0x26+1] = blCmds.discoverAPs.tx -- "discoverAPs" request ID -- always gets done last insertChecksum(msgTab) return msgTab end -- The "pairing" message will be sent to the "BroadLinkProv" AP -- Once sucessfully received, the BroadLink device deletes its -- AP function and changes to a slave, connected to our LAN. -- That's assuming the correct SSID and PASS were made use of. -- The tx'ed msg carrys no payload local function makePairingMsg() local securityType = 0x03 -- WPA2 local ssidLen = string.len(SSID) local passLen = string.len(PASS) -- 8 byte QUIC header + 128 byte payload = 136 dec = 0x88 local msgTab = makeEmptyTable(0x88) -- insert the SSID starting at 0x44 (68 dec), SSID can be up to 32 chars local charIdx = 0x44+1 for c in SSID:gmatch('.') do msgTab[charIdx] = string.byte(c) charIdx = charIdx+1 end -- insert the PASS starting at 0x64 (100 dec), PASS can be up to 32 chars charIdx = 0x64+1 for c in PASS:gmatch('.') do msgTab[charIdx] = string.byte(c) charIdx = charIdx+1 end msgTab[0x26+1] = blCmds.pairing.tx -- "pairing" request ID msgTab[0x84+1] = ssidLen -- insert ssid length msgTab[0x85+1] = passLen -- insert pw length msgTab[0x86+1] = securityType -- request WPA2 -- always gets done last insertChecksum(msgTab) return msgTab end -- Make the BroadLink "discover Devices" message local function makeDiscoverDevicesMsg() local ipa1, ipa2, ipa3, ipa4 = OUR_IP:match('^(%d%d?%d?)%.(%d%d?%d?)%.(%d%d?%d?)%.(%d%d?%d?)') -- note that JavaScript getTimezoneOffset returns the offset including dst in minutes -- note that get_timezone_offset is of the opposite sign to JavaScript getTimezoneOffset local tz, date = get_timezone_offset() -- seconds tz = -tz/3600 -- tz in hours with correct sign local tzMsb, tzIsb1, tzIsb2, tzLsb = 0, 0, 0, tz -- in hours -- if tz is negative make a 32 bit negative integer if (tz < 0) then tzMsb, tzIsb1, tzIsb2 = 0xff, 0xff, 0xff tzLsb = (0xff + tz - 1) % 0xff end local port = UDP_IP_PORT local yearMsb = math.floor(date.year/256) local portMsb = math.floor(port/256) -- 8 byte QUIC header + 40 byte payload = 48dec = 0x30 local msgTab = makeEmptyTable(0x30) msgTab[0x08+1] = tzLsb msgTab[0x09+1] = tzIsb2 msgTab[0x0a+1] = tzIsb1 msgTab[0x0b+1] = tzMsb -- 32 bit int in hours msgTab[0x0c+1] = date.year - (yearMsb * 256) msgTab[0x0d+1] = yearMsb msgTab[0x0e+1] = date.sec msgTab[0x0f+1] = date.min msgTab[0x10+1] = date.hour msgTab[0x11+1] = date.day msgTab[0x12+1] = date.wday -1 msgTab[0x13+1] = date.month msgTab[0x18+1] = ipa4 msgTab[0x19+1] = ipa3 msgTab[0x1a+1] = ipa2 msgTab[0x1b+1] = ipa1 msgTab[0x1c+1] = port - (portMsb * 256) msgTab[0x1d+1] = portMsb msgTab[0x26+1] = blCmds.discoverDevices.tx -- "discover Devices" request ID -- always gets done last insertChecksum(msgTab) return msgTab end -- Make the BroadLink "auth" message local function makeAuthorisationMsg(blId) local payloadTab = makeEmptyTable(0x50) -- 80 bytes long -- note that blInternalId at payloadTab[0x00+1] to payloadTab[0x03+1] is (already) set to 0x00 -- insert the key starting at 0x04+1, key is 2*16 chars long -- 097628343fe99e23765c1513accf8b02 local charIdx = 0x04+1 for c in blDevices[blId].blKey:gmatch('%x%x') do --debug(string.format('%02x',tonumber(c,16))) payloadTab[charIdx] = tonumber(c,16) charIdx = charIdx+1 end payloadTab[0x2d+1] = 0x01 -- add in delimiter --[[ apparently not required, but may be of use? -- insert friendly device name starting at 0x30 = 48dec myDevName = 'My device' charIdx = 0x30+1 for c in myDevName:gmatch('.') do payloadTab[charIdx] = string.byte(c) charIdx = charIdx+1 end ]] return headerAndPayload(blId, payloadTab, blCmds.auth.tx) end -- A simple single byte command message to readWrite.tx = 0x6a local function makeSimpleMsg(blId, packetLength, command) local payloadTab = makeEmptyTable(packetLength) payloadTab[0x00+1] = command -- insert payload "request" header insertPayloadHeader(payloadTab, 1, blId) return headerAndPayload(blId, payloadTab, blCmds.readWrite.tx) end -- Make the BroadLink SP1 "single relay off/on" message local function makeSP1RelayMsg(blId, offOn) local payloadTab = makeEmptyTable(0x04) -- 4 bytes long -- we'll issue the off/on command payloadTab[0x00+1] = plCmds.off -- on selected if (offOn) then payloadTab[0x00+1] = plCmds.on end return headerAndPayload(blId, payloadTab, blCmds.sp1.tx) end -- Make the BroadLink SP2 "single relay off/on" message local function makeSP2RelayMsg(blId, offOn) local payloadTab = makeEmptyTable(0x10) -- 16 bytes long -- we'll issue the off/on command payloadTab[0x00+1] = plCmds.set payloadTab[0x04+1] = plCmds.off -- on selected if (offOn) then payloadTab[0x04+1] = plCmds.on end return headerAndPayload(blId, payloadTab, blCmds.readWrite.tx) end -- Make the BroadLink "get energy" message local function makeGetEnergyMsg(blId) local payloadTab = makeEmptyTable(0x0a) -- 10 bytes long -- we'll issue the get energy command: Watt-hour (Wh) payloadTab[0x00+1] = plCmds.energyGet payloadTab[0x02+1] = 0xfe -- 254d payloadTab[0x03+1] = 0x01 payloadTab[0x04+1] = 0x05 payloadTab[0x05+1] = 0x01 payloadTab[0x09+1] = 0x2d -- 45d return headerAndPayload(blId, payloadTab, blCmds.readWrite.tx) end -- Make the BroadLink "IR & RF" message local function makeTxIrRfMsg(blId, irRfCodeTab) -- UDP_MAX_PAYLOAD Warning: a host is not required to receive a datagram -- larger than 576 byte. So far not an issue with the BroadLink devices. -- length of the payload = 0x04 = 4dec + passed in data local payloadTab = makeEmptyTable(0x04) -- we'll issue the set command payloadTab[0x00+1] = plCmds.set -- Insert "data send" payload header insertPayloadHeader(payloadTab, 2, blId) if (irRfCodeTab) then -- append the IR/RF data table to the payload table for _,v in ipairs(irRfCodeTab) do table.insert(payloadTab, v) end else debug('Error: irRfCodeTab is nil',50) end return headerAndPayload(blId, payloadTab, blCmds.readWrite.tx) end -- Make the BroadLink "relay off/on" message for a MP1 power strip local function makeMP1RelayMsg(blId, offOn, relay) local payloadTab = makeEmptyTable(0x10) -- 16 bytes long -- we'll issue the mp1Strip relay command payloadTab[0x00+1] = plCmds.mp1RlySw payloadTab[0x02+1] = 0xa5 payloadTab[0x03+1] = 0xa5 payloadTab[0x04+1] = 0x5a payloadTab[0x05+1] = 0x5a payloadTab[0x06+1] = 0xb2 + swMask -- off payloadTab[0x07+1] = 0xc0 payloadTab[0x08+1] = 0x02 payloadTab[0x0a+1] = 0x03 payloadTab[0x0d+1] = swMask payloadTab[0x0e+1] = 0x00 -- off -- shift the mask to the relay selected local swMask = 0x01 for i = 2, relay-1 do swMask = swMask*2 end -- on selected if (offOn) then payloadTab[0x06+1] = 0xb2 + (swMask*2) payloadTab[0x0e+1] = swMask end return headerAndPayload(blId, payloadTab, blCmds.readWrite.tx) end -- Make the BroadLink "read power" message for a MP1 power strip local function makeMP1StatusMsg(blId) local payloadTab = makeEmptyTable(0x10) -- 16 bytes long -- we'll issue the mp1Strip read power command payloadTab[0x00+1] = plCmds.mp1RlyStatus payloadTab[0x02+1] = 0xa5 payloadTab[0x03+1] = 0xa5 payloadTab[0x04+1] = 0x5a payloadTab[0x05+1] = 0x5a payloadTab[0x06+1] = 0xae payloadTab[0x07+1] = 0xc0 payloadTab[0x08+1] = 0x01 return headerAndPayload(blId, payloadTab, blCmds.readWrite.tx) end -- Master send and receive with decrypted payload extraction local function sendReceive(msgType, txMsgTab, blId) local ok = false -- these values are used by the pairing message local ipAddress = BROADLINK_AP_IP local key = nil -- everything else needs the device's ip address and key if (blId) then ipAddress = blDevices[blId].blIp key = blDevices[blId].blKey end -- the send routine requires a string local strTab = {} -- table.concat does coercion of numbers, which we don't want. -- Here, we are effectively setting all the elements to char type -- as the send routine requires a string for i=1, #txMsgTab do table.insert(strTab, string.char(txMsgTab[i])) end local txMsg = table.concat(strTab) local txMsgLen = txMsg:len() -- sanity check: each table element should only be one byte if (txMsgLen ~= #txMsgTab) then debug('Error: not all the table elements are a single byte long',50) end local udp = socket.udp() udp:settimeout(MSG_TIMEOUT) debug('Sending: '..msgType..': txMsg length = '..txMsgLen) -- Note: the maximum datagram size for UDP is (potentially) 576 bytes local resultTX, errorMsg = udp:sendto(txMsg, ipAddress, UDP_IP_PORT) if (resultTX == nil) then debug('TX of '..msgType..' msg to '..ipAddress..' failed: '..errorMsg) udp:close() return ok end -- Note: aircon codes can be very long. Buffer overruns of rx'ed messages will throw a checksum error. local rxMsg, ipOrErrorMsg = udp:receivefrom() udp:close() if (rxMsg == nil) then debug('RX of '..msgType..' msg response from '..ipAddress..' failed: '..ipOrErrorMsg) return ok end -- convert the rx'ed msg to a byte table - we like tables local rxMsgTab = {} for c in rxMsg:gmatch('.') do table.insert(rxMsgTab, string.byte(c)) end local rxMsgLen = #rxMsg local deviceMsg = string.format('%02x%02x', rxMsgTab[0x25+1], rxMsgTab[0x24+1]) local replyMsg = string.format('%02x%02x', rxMsgTab[0x27+1], rxMsgTab[0x26+1]) debug('Broadlink device: '..deviceMsg..' replied with: '..replyMsg) -- have a look at the error information returned -- 0xfff9 means an error of some sort. Seems to occur if the WiFi signal is marginal. The payload will be nil. -- 0xfff6 is returned when no IR/RF code has been learnt? The payload will be nil. local errorMsg = string.format('%02x%02x', rxMsgTab[0x23+1], rxMsgTab[0x22+1]) if (errorMsg ~= '0000') then debug('Error: errorMsg = '..errorMsg,50) return ok end -- HACK if ((errorMsg ~= '0000') and (errorMsg ~= 'fff6')) then debug('Error: errorMsg = '..errorMsg,50) return ok end if (not validChecksum(rxMsgTab)) then debug('Error: rx\'ed msg checksum incorrect',50) return ok end -- get the header ready just for debugging local headerTab = {} for i=1, 0x37+1 do headerTab[i] = rxMsgTab[i] end -- get the received payload starting at 56d=0x38 (zero based count as per the references) -- it may or may not be (ie "pairing msg response") encrypted local rxedPayloadTab = {} for i = 0x38+1, rxMsgLen do table.insert(rxedPayloadTab, rxMsgTab[i]) end if (#rxedPayloadTab == 0) then debug('Received: '..msgType..': rxMsg length = '..tostring(rxMsgLen)) tableDump('No payload found. Header follows:', headerTab) return ok end -- decrypt the payload -- The "pairing" message doesn't encrypt/decrypt, so the key passed into this function will be -- nil on that occasion. Before authorisation is completed, the key will equal the "initialKey". -- After authorisation it will be the key supplied by the discovery process. local payloadTab = {} if (m_doEncodeDecode and key) then payloadTab = encryptDecrypt(key, rxedPayloadTab, false) else -- payload is not encrypted payloadTab = rxedPayloadTab end if ((#payloadTab > 0) and (not validPayloadChecksum(rxMsgTab, payloadTab))) then debug('Error: rx\'ed payload checksum incorrect',50) return ok end -- show the full received message complete with decrypted payload tableDump('Received: '..msgType..': rxMsg length = '..tostring(rxMsgLen)..' decrypted msg follows:', headerTab) tableDump('Rx\'ed payload follows:', payloadTab) ok = true return ok, payloadTab end --[[ Send the "pairing" message to the "BroadLinkProv" AP returns true if the pairing was successful NOTE: for this to work your Vera or openLuup device must already be connected to the WiFi AP called "BroadlinkProv". Right from the start, you should be able to detect the AP with: ping -c 10 192.168.10.2 On an Arduino you need to append to this file: /etc/wpa_supplicant/wpa_supplicant.conf the following: # connect to a Broadlink provisioning AP network={ ssid="BroadlinkProv" key_mgmt=NONE } A reboot of the Arduino is then required. sudo reboot or perhaps just reboot. iwinfo should indicate if the Arduino is connected to the AP before we do the pairing -- This code works but is not called. You need to provide your own SID and PASS. ]] local function sendPairingMsg() -- Use the factory default BroadLink WiFi access point ip address. -- Note that the pairing msg contains no payload. -- Note the response contains no checksum local ok = sendReceive('Pairing', makePairingMsg()) return ok end -- Broadcast the "discover devices" message - as opposed to discovering APs msg local function broadcastDiscoverDevicesMsg() local ok = false local udp = socket.udp() udp:settimeout(MSG_TIMEOUT) local txMsgTab = makeDiscoverDevicesMsg() -- HACK local txMsgTab = makeDiscoverAPsMsg() -- can't get this to work! but it would be called like this, in this sort of framework. -- the send routine requires a string local strTab = {} -- table.concat does coercion of numbers, which we don't want. -- Here, we are effectively setting all the elements to char type for i=1, #txMsgTab do table.insert(strTab, string.char(txMsgTab[i])) end local txMsg = table.concat(strTab) local BROADCAST_IP = '255.255.255.255' -- HACK local MULTICAST_IP = '224.0.0.251' -- asterisk represents all the local interfaces on Vera eg Lan, WiFi, etc local setOK, failMsg = udp:setsockname('*', UDP_IP_PORT) if (setOK == nil) then debug('Set socket name failed: '..failMsg,50) udp:close() return ok end udp:setoption('broadcast', true) debug('Broadcasting discovery message') local resultTX, errorMsg = udp:sendto(txMsg, BROADCAST_IP, UDP_IP_PORT) -- HACK local resultTX, errorMsg = udp:sendto(txMsg, MULTICAST_IP, UDP_IP_PORT) if (resultTX == nil) then debug('Broadcast TX failed: '..errorMsg) udp:close() return ok end local rxMsg = nil local ipOrErrorMsg = '' -- repeat until the queue of all the device responses has been processed repeat -- allow for a msg length of 512. The receivefrom() function will block until timeout rxMsg, ipOrErrorMsg, _ = udp:receivefrom(512) if (rxMsg) then debug(ipOrErrorMsg) -- as the responses to the broadcast are rx'ed, add the devices to the list addToBroadlinkPhysicalDevicesList(rxMsg, ipOrErrorMsg) end until (not rxMsg) udp:close() return ok end -- Send the "auth" message to each BroadLink devices. This loads blKey & blInternalId local function getAuthorisation() for blId,_ in pairs(blDevices) do local ok, payloadTab = sendReceive('Authorisation', makeAuthorisationMsg(blId), blId) if (ok) then -- extract the blInternalId from the response local strTab = {} for i = 3+1, 0+1, -1 do table.insert(strTab, string.format('%02x', payloadTab[i])) end blDevices[blId].blInternalId = table.concat(strTab) -- extract the key from the response strTab = {} for i = 4+1, 19+1 do table.insert(strTab, string.format('%02x', payloadTab[i])) end blDevices[blId].blKey = table.concat(strTab) debug(string.format('blKey: %s, blInternalId: %s', blDevices[blId].blKey, blDevices[blId].blInternalId),50) else debug('This device is probably offline - mac address: '..blId) end end end -- Get the temperature from a BroadLink device -- returns true if the get status was successful local function getTemperature(blId) local ok, payloadTab = sendReceive('Get temperature', makeSimpleMsg(blId, 0x10, plCmds.get), blId) if (not ok) then return ok end -- extract the msb temperature status from the payload local msb = payloadTab[plData.temperature.msb] -- For reasons completely unknown, the RM Pro randomly returns 0xf9 = 249 dec in the msb. We'll -- quard against this by considering any temperature that's over 70 deg C as being implausible. if (msb >= 70) then return false end local temperature = msb + payloadTab[plData.temperature.lsb]/10 return ok, temperature end -- Get the energy from a BroadLink device -- returns true if the get status was successful local function getEnergy(blId) local ok, payloadTab = sendReceive('Get energy', makeGetEnergyMsg(blId), blId) if (not ok) then return ok end -- extract the energy status from the payload in Watt-hour (Wh) local energy = payloadTab[plData.energy.msb]*256 + payloadTab[plData.energy.isb] + payloadTab[plData.energy.lsb]/100 return ok, energy end -- Get the relay status from a BroadLink device -- returns true if the get status was successful local function updateStatus(blId, lul_device, relay) local ok = false local payloadTab = {} local status = 0 local nightLight = 0 local blDeviceType = blDevices[blId].blDeviceType -- all devices are considered to be a single relay except the MP1 & SP1 if (blDeviceType == 0x4ef7) then -- MP1 power strip with multiple relays -- 0x00 (off) or 0x01 (on) for each relay bit ok, payloadTab = sendReceive('Get status: MP1 relays', makeMP1StatusMsg(blId), blId) if (not ok) then return ok end -- extract the relay status for each relay local result = payloadTab[plData.status] -- mask off the unused upper bits, keeping bits 0-3 local result = result % 2^4 -- scan though the four relays looking for the one of interest. A bit library would be good. for n = 3, 0, -1 do local product = 2^n if (result >= product) then -- bit is set if (relay == n+1) then status = 1 end result = result-product end end elseif (blDeviceType == 0x0000) then -- SP1 relay with no status feedback -- HACK elseif ((blDeviceType == 0x0000) or (blDeviceType == 0x2787)) then -- TESTING -- Do SP1s make their status available? - seems that they don't. So there is nothing to do. return true else -- single relay and maybe a night light: refer SP3 ok, payloadTab = sendReceive('Get status: single relay', makeSimpleMsg(blId, 0x10, plCmds.get), blId) if (not ok) then return ok end local result = payloadTab[plData.status] -- mask off the unused upper bits, keeping bits 0-1 result = result % 2^2 if (result >= 2) then nightLight = 1 end -- bit 1 is the night light result = result % 2^1 if (result == 1) then status = 1 end -- bit 0 is the power outlet end status = tostring(status) updateVariable('Status', status, SID.BINARY_LIGHT, lul_device) return ok end -- Scan the BroadLink device for a learnt IR code. Function needs to be global. function scanningForBroadlinkIrCode(blId) m_IRScanCount = m_IRScanCount -1 -- No msg rx'ed. Scanning failed to get a learnt code. if (m_IRScanCount == 0) then m_PollEnable = m_PollLastState return end -- send the "have we got a learnt code" command local ok, payloadTab = sendReceive('Scanning for learnt code', makeSimpleMsg(blId, 0x10, plCmds.irGetCode), blId) -- Keep scanning if no message is Rx'ed or the returned errorMsg ~= '0000'. Note that the returned errorMsg -- can be 'fff6', which appears to indicate that no IR code has been found so far: Refer to sendreceive() if (not ok) then luup.call_delay('scanningForBroadlinkIrCode', MSG_TIMEOUT +3, blId) return end -- Remove the payload header/prefix removePayloadHeader(payloadTab, blId) -- Got a learnt code. Extract the code from the payload local codeTab = {} for n = plData.irCodeIdx0, #payloadTab do table.insert(codeTab, string.format('%02x', payloadTab[n])) end updateVariable('LearntIRCode', table.concat(codeTab, ' ')) -- restore the last polling state m_PollEnable = m_PollLastState end -- Start the scan for a learnt IR code local function lookForLearntIrCode(blId) -- disable polling. Note this effects all Broadlink devices in use. m_PollLastState = m_PollEnable m_PollEnable = '0' updateVariable('LearntIRCode', 'No IR code was learnt') -- enter learning mode local ok, payloadTab = sendReceive('Start IR learn', makeSimpleMsg(blId, 0x10, plCmds.irLearnStart), blId) if (not ok) then m_PollEnable = m_PollLastState return end -- note: the Broadlink RM PRO & RM Mini 3 automatically stop the IR learning mode after 30 seconds -- check for a learnt IR code every 4 seconds for 28 seconds -- we can't scan any faster than the rx msg timeout m_IRScanCount = 7 luup.call_delay('scanningForBroadlinkIrCode', MSG_TIMEOUT +3, blId) end -- Start the scan for a learnt RF code. Function must be global. function lookForLearntBroadlinkRfCode(blId) if (m_RfScanningState == RF.START) then -- Disable polling. Note this effects all Broadlink devices in use. m_PollLastState = m_PollEnable m_PollEnable = '0' updateVariable('LearntRFCode', 'No RF code was learnt') -- Enter learning mode. The reply payload is just the TX'ed command echoed back, plus 15 0x00s to make up 16 bytes for AES. -- That is: 0x19 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 local ok, payloadTab = sendReceive('Start RF Remote learn', makeSimpleMsg(blId, 0x10, plCmds.rfLearnStart), blId) if (ok) then -- check for a learnt RF frequency every 4 seconds for 4*8=32 seconds m_RFScanCount = 8 m_RfScanningState = RF.GET_FREQ else m_RfScanningState = RF.ABORT_1 end elseif (m_RfScanningState == RF.GET_FREQ) then m_RFScanCount = m_RFScanCount-1 -- Enter step 1 of RF learning. This checks the RF frequency. The reply is the TX'ed command -- echoed back, a found flag and 0x00s to make up 16 bytes for AES. Flag is at 0x04+1 -- Not found: 0x1A 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 -- Found: 0x1A 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 -- Given up: 0x1A 0x00 0x00 0x00 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 local ok, payloadTab = sendReceive('Scanning for the Remote\'s frequency', makeSimpleMsg(blId, 0x10, plCmds.rfLearnFreq), blId) if (ok and payloadTab and (payloadTab[plData.rfFoundFlag] == 0x01)) then debug('Remote frequency found') -- check for a learnt RF code every 4 seconds for 4*8=32 seconds m_RFScanCount = 8 m_RfScanningState = RF.GET_CODE elseif (ok and payloadTab and (payloadTab[plData.rfFoundFlag] == 0x04)) then debug('Given up on finding Remote frequency') m_RfScanningState = RF.ABORT_2 elseif (m_RFScanCount <= 0) then debug('Comms error finding Remote frequency') m_RfScanningState = RF.ABORT_2 end -- keep scanning if no message is Rx'ed elseif (m_RfScanningState == RF.GET_CODE) then m_RFScanCount = m_RFScanCount-1 -- Enter step 2 of RF learning. This checks for the RF code. The reply is the TX'ed command -- echoed back, a found flag and 0x00s to make up 16 bytes for AES. Flag is at 0x04+1 -- Not found: 0x1B 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 -- Found: 0x1B 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 local ok, payloadTab = sendReceive('Scanning for the Remote\'s code', makeSimpleMsg(blId, 0x10, plCmds.rfLearnCode), blId) if (ok and payloadTab and (payloadTab[plData.rfFoundFlag] == 0x01)) then debug('Remote code found') m_RfScanningState = RF.DONE elseif (m_RFScanCount <= 0) then m_RfScanningState = RF.ABORT_2 end elseif (m_RfScanningState == RF.DONE) then -- Scan comnplete. Stop the scanning and get the learnt code. -- send the "have we got a learnt code" command local ok, payloadTab = sendReceive('Retrieving learnt frequency and code', makeSimpleMsg(blId, 0x10, plCmds.irGetCode), blId) if (ok) then -- Got a learnt code. Extract the code from the payload. local codeTab = {} for n = plData.rfCodeIdx0, #payloadTab do table.insert(codeTab, string.format('%02x', payloadTab[n])) end updateVariable('LearntRFCode', table.concat(codeTab, ' ')) m_PollEnable = m_PollLastState local ok, payloadTab = sendReceive('Stop RF learn', makeSimpleMsg(blId, 0x10, plCmds.rfLearnStop), blId) return -- success!! else m_RfScanningState = RF.ABORT_1 end else debug('Error: unknown state - aborting') m_RfScanningState = RF.ABORT_1 end -- did we fail to start or stop the scan? if (m_RfScanningState == RF.ABORT_1) then debug('Error: comms error - aborting') m_PollEnable = m_PollLastState return end -- did something go wrong during the scan? if (m_RfScanningState == RF.ABORT_2) then -- No msg rx'ed. Scanning failed to get a learnt frequency or code. debug('RF code learning failed to get a frequency and/or code',50) local ok, payloadTab = sendReceive('Stop RF learn', makeSimpleMsg(blId, 0x10, plCmds.rfLearnStop), blId) m_PollEnable = m_PollLastState return end -- This is a four second delay. -- We can't scan any faster than the rx msg timeout luup.call_delay('lookForLearntBroadlinkRfCode', MSG_TIMEOUT +3, blId) end -- Send IR & RF messages or learn IR or RF codes local function ctrlrRf(blId, func, dataTab) if (not func) then return elseif (func == 1) then -- TX an IR or RF code local ok, payloadTab = sendReceive('Tx IR RF', makeTxIrRfMsg(blId, dataTab), blId) elseif (func == 2) then lookForLearntIrCode(blId) elseif (func == 3) then m_RfScanningState = RF.START lookForLearntBroadlinkRfCode(blId) else debug('Invalid function number') end end -- Send relay off/on message: SP1 -- returns true if the set was successful local function SP1offOn(blId, lul_device, offOn) local ok = sendReceive('Relay off/on', makeSP1RelayMsg(blId, offOn), blId) -- Update the status for this device no matter what. With -- no status feedback, it's the best we can do. if (offOn) then updateVariable('Status', '1', SID.BINARY_LIGHT, lul_device) else updateVariable('Status', '0', SID.BINARY_LIGHT, lul_device) end return ok end -- Send relay off/on messageL SP2 -- returns true if the set was successful local function SP2offOn(blId, lul_device, offOn) local ok = sendReceive('Relay off/on', makeSP2RelayMsg(blId, offOn), blId) if (ok) then ok = updateStatus(blId, lul_device) end return ok end -- Send relays (plural) off/on message: MP1 -- returns true if the set was successful local function MP1offOn(blId, lul_device, offOn) local ok = false -- altId contains the relay number to use local altId = luup.devices[lul_device].id local _, _, relay = string.find(altId, 'rly(%d)') relay = tonumber(relay) if (relay) then ok = sendReceive('Relay off/on', makeMP1RelayMsg(blId, offOn, relay), blId) if (ok) then ok = updateStatus(blId, lul_device, relay) end end return ok end -- lul_device is the device ID (a number). lul_settings is a table with all the arguments to the action. local function validatePtrs(lul_device) local altId = luup.devices[lul_device].id if (not veraDevices[altId]) then return false end local blId = veraDevices[altId].blId local veraFunc = veraDevices[altId].veraFunc -- look up the function if (veraFunc and blId) then return true, blId, veraFunc end return false end -- Service: relay on/off local function setTarget(lul_device, newTargetValue) local offOn = (tonumber(newTargetValue) == 1) local ok, blId, veraFunc = validatePtrs(lul_device) -- function is SP1offOn(), SP2offOn() or MP1offOn() if (ok) then veraFunc(blId, lul_device, offOn) end end -- Service: send an IR pronto code or a Broadlink IR or Broadlink RF code local function sendCode(lul_device, irRfCode) debug('Broadlink IR code 2 type = '..type(irRfCode)) if (not irRfCode) then return end if (type(irRfCode) ~= 'string') then return end if (20 >= irRfCode:len()) then return end local ok, blId, veraFunc = validatePtrs(lul_device) -- function is ctrlrRf(blId, 1, irCodeTab) if (not ok) then return end local irRfCode = irRfCode:lower() local pCodeTst = irRfCode:sub(1,4) local blCodeTst = irRfCode:sub(1,2) local irCodeTab = {} if (pCodeTst == '0000') then -- Pronto code irCodeTab = prontoCode2blCode(irRfCode) -- BroadLink code 0x26 = IR, 0xd7 for RF 315MHz, 0xb2 for RF 433MHz elseif ((blCodeTst == '26') or (blCodeTst == 'd7') or (blCodeTst == 'b2')) then -- convert the ir string to a byte table local n = 0 for c in irRfCode:gmatch('%x%x') do debug(c) n = tonumber(c,16) if (n == nil) then debug('Invalid IR code - not all hexadecimal',50) return end table.insert(irCodeTab, n) end else debug('Invalid IR code',50) return end -- for debugging purposes only if (DEBUG_MODE) then local irCodeTabHex = {} for _,v in ipairs(irCodeTab) do table.insert(irCodeTabHex, string.format('%02x', v)) end debug('Broadlink IR code 2 = '..table.concat(irCodeTabHex,' ')) end -- function 1 is send IR or RF code veraFunc(blId, 1, irCodeTab) end -- service: send a Pronto code local function sendProntoCode(lul_device, ProntoCode) sendCode(lul_device, ProntoCode) end --[[ service: send a Broadlink eControl code Sample input code: as an array: {-78, 6, 28, 0, 12, 14, 15, 26, 27, 15, 15, 26, 15, 26, 15, 26, 15, 26, 15, 26, 15, 26, 15, 27, 26, 15, 27, 15, 27, 0, 2, 93, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} as a string - commas optional: '-78, 6, 28, 0, 12, 14, 15, 26, 27, 15, 15, 26, 15, 26, 15, 26, 15, 26, 15, 26, 15, 26, 15, 27, 26, 15, 27, 15, 27, 0, 2, 93, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0' Sample output result: 'b2 06 1c 00 0c 0e 0f 1a 1b 0f 0f 1a 0f 1a 0f 1a 0f 1a 0f 1a 0f 1a 0f 1b 1a 0f 1b 0f 1b 00 02 5d 00 00 00 00 00 00 00 00 00 00 00 00' ]] local function sendEControlCode(lul_device, eControlCode) if (type(eControlCode) == 'string') then local eControlCodeStr = eControlCode eControlCode = {} for code in eControlCodeStr:gmatch('%-?%d+') do table.insert(eControlCode, tonumber(code)) end end local hexTab = {} for _,v in ipairs(eControlCode) do -- Handle negative values: eg 433MHz leadin code is 0xb2 ie 178dec -- In the e-control json, it is -78dec. So 256+(-78) = 178dec = 0xb2 if (v < 0) then v = 256 + v end table.insert(hexTab, string.format('%02x', v)) end local irRfCode = table.concat(hexTab,' ') sendCode(lul_device, irRfCode) end -- Service: learn a Broadlink IR code local function learnIRCode(lul_device) local ok, blId, veraFunc = validatePtrs(lul_device) -- function is ctrlrRf(blId, 2) if (not ok) then return end -- function 2 is learn IR code veraFunc(blId, 2) end -- Service: learn a Broadlink RF code local function learnRFCode(lul_device) local ok, blId, veraFunc = validatePtrs(lul_device) -- function is ctrlrRf(blId, 3) if (not ok) then return end -- function 3 is learn RF code veraFunc(blId, 3) end -- Map BroadLink hex ids to friendly labels local function setBlLabels() --[[ Possible other devices not yet described: RM Pro: 433 only with temp sensor: blDeviceType 9863dec = 2687h RM Pro: 433 and 315 with no temp sensor: blDeviceType 9885dec = 269dh ]] blDevs = { [0x0000] = {desc = 'SP1' }, [0x2711] = {desc = 'SP2' }, [0x2719] = {desc = 'SP2' }, [0x7919] = {desc = 'SP2' }, [0x271a] = {desc = 'SP2' }, [0x791a] = {desc = 'SP2 Honeywell' }, [0x753e] = {desc = 'SP3' }, [0xBEEF] = {desc = 'SP3S' }, [0x2720] = {desc = 'SPMini' }, [0x2728] = {desc = 'SPMini2' }, [0x2733] = {desc = 'SPMini2' }, [0x273e] = {desc = 'SPMini OEM' }, [0x2736] = {desc = 'SPMiniPlus' }, [0x7547] = {desc = 'SC1' }, [0x4ef7] = {desc = 'MP1' }, [0x2712] = {desc = 'RM2' }, [0x2737] = {desc = 'RM Mini' }, [0x273d] = {desc = 'RM Pro Phicomm' }, [0x2783] = {desc = 'RM2 Home Plus' }, [0x277c] = {desc = 'RM2 Home Plus GDT' }, [0x272a] = {desc = 'RM2 Pro Plus' }, [0x2787] = {desc = 'RM2 Pro Plus 2' }, [0x278b] = {desc = 'RM2 Pro Plus BL' }, [0x279d] = {desc = 'RM3 Pro Plus' }, [0x278f] = {desc = 'RM Mini Shate' }, [0x2714] = {desc = 'A1' }, [0x2722] = {desc = 'S1 SmartOne Alarm Kit' }, [0x4e4d] = {desc = 'Dooya DT360E' }, -- added May 2020 [0x27a9] = {desc = 'RM2 Pro Plus_300' }, [0x2797] = {desc = 'RM2 Pro Plus HYC' }, [0x4e4d] = {desc = 'RM2 Pro Plus R1' }, [0x4e4d] = {desc = 'RM2 Pro Plus PP' }, -- compliments of bblacey - thank you: devices with new leadin arrangements: [0x51da] = {desc = 'RM4 Mini' }, [0x5f36] = {desc = 'RM3 Mini' }, [0x6026] = {desc = 'RM4 Pro' }, [0x6070] = {desc = 'RM4 Mini' }, [0x610e] = {desc = 'RM4 Mini' }, [0x610f] = {desc = 'RM4 Mini' }, [0x62bc] = {desc = 'RM4 Mini' }, [0x62be] = {desc = 'RM4 Mini' } } end -- Map BroadLink functionality to the functions that will do the actual work. Stick -- all this stuff here, so we don't end up with forward references to the called functions local function setDeviceConfiguration() setBlLabels() -- add in the empty devs arrays, then fill them in for k,_ in pairs(blDevs) do blDevs[k].devs = {} end local ptr = nil blDevs[0x0000].devs.rly1 = SP1offOn -- 'SP1' has no status feedback? blDevs[0x2711].devs.rly1 = SP2offOn -- 'SP2' SP2 & SP3 have energy sensors blDevs[0x2719].devs.rly1 = SP2offOn -- 'SP2' blDevs[0x7919].devs.rly1 = SP2offOn -- 'SP2' blDevs[0x271a].devs.rly1 = SP2offOn -- 'SP2' blDevs[0x791a].devs.rly1 = SP2offOn -- 'SP2 Honeywell' blDevs[0x753e].devs.rly1 = SP2offOn -- 'SP3' ptr = blDevs[0xBEEF].devs -- 'SP3S' ptr.rly1 = SP2offOn -- ptr.em1 = nil -- has energy meter -- blDevs[0x2720].devs.rly1 = SP2offOn -- 'SPMini' blDevs[0x2728].devs.rly1 = SP2offOn -- 'SPMini2' blDevs[0x2733].devs.rly1 = SP2offOn -- 'SPMini2' blDevs[0x273e].devs.rly1 = SP2offOn -- 'SPMini OEM' blDevs[0x2736].devs.rly1 = SP2offOn -- 'SPMiniPlus' blDevs[0x7547].devs.rly1 = SP2offOn -- 'SC1' has status feedback? ptr = blDevs[0x4ef7].devs -- 'MP1' ptr.rly1 = MP1offOn -- ptr.rly2 = MP1offOn -- ptr.rly3 = MP1offOn -- ptr.rly4 = MP1offOn -- blDevs[0x2712].devs.ir = ctrlrRf -- 'RM2' blDevs[0x2737].devs.ir = ctrlrRf -- 'RM Mini' ptr = blDevs[0x273d].devs -- 'RM Pro Phicomm' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr = blDevs[0x2783].devs -- 'RM2 Home Plus' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr = blDevs[0x277c].devs -- 'RM2 Home Plus GDT' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr = blDevs[0x272a].devs -- 'RM2 Pro Plus' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr = blDevs[0x2787].devs -- 'RM2 Pro Plus 2' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr.rf315 = ctrlrRf -- ptr.rf433 = ctrlrRf -- -- HACK ptr.rly1 = SP1offOn -- HACK TESTING ptr = blDevs[0x278b].devs -- 'RM2 Pro Plus BL' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr = blDevs[0x279d].devs -- 'RM3 Pro Plus' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr.rf315 = ctrlrRf -- ptr.rf433 = ctrlrRf -- blDevs[0x278f].devs.ir = ctrlrRf -- 'RM Mini Shate' ptr = blDevs[0x2714].devs -- 'A1' ptr.humidity = nil -- ptr.lightLevel = nil -- ptr.noise = nil -- ptr.temp = nil -- ptr.voc = nil -- ptr = blDevs[0x2722].devs -- 'S1 SmartOne Alarm Kit' ptr.keyFob = nil -- Note: polling is too slow to make this item viable ptr.motionSensor = nil -- Note: polling is too slow to make this item viable ptr.doorSensor = nil -- Note: polling is too slow to make this item viable ptr = blDevs[0x4e4d].devs -- 'Dooya DT360E' -- add in whatever a 'Dooya DT360E' does here -- -- added May 2020: no idea what these do but will assume they can do IR & temp ptr = blDevs[0x27a9].devs -- 'RM2 Pro Plus_300' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr = blDevs[0x2797].devs -- 'RM2 Pro Plus HYC' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr = blDevs[0x4e4d].devs -- 'RM2 Pro Plus R1' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- ptr = blDevs[0x4e4d].devs -- 'RM2 Pro Plus PP' ptr.ir = ctrlrRf -- ptr.temp = getTemperature -- -- compliments of bblacey - thank you: devices with new leadin arrangements: blDevs[0x51da].devs.ir = ctrlrRf -- 'RM4b Mini' blDevs[0x51da].plHdrs = {0x0004, 0x000d} -- blDevs[0x5f36].devs.ir = ctrlrRf -- 'RM3 Mini' blDevs[0x5f36].plHdrs = {0x0004, 0x000d} -- blDevs[0x6026].devs.ir = ctrlrRf -- 'RM4 Pro' blDevs[0x6026].plHdrs = {0x0004, 0x000d} -- blDevs[0x6070].devs.ir = ctrlrRf -- 'RM4 Pro' blDevs[0x6070].plHdrs = {0x0004, 0x000d} -- blDevs[0x610e].devs.ir = ctrlrRf -- 'RM4? Mini' blDevs[0x610e].plHdrs = {0x0004, 0x000d} -- blDevs[0x610f].devs.ir = ctrlrRf -- 'RM4c Mini' blDevs[0x610f].plHdrs = {0x0004, 0x000d} -- blDevs[0x62bc].devs.ir = ctrlrRf -- 'RM4c Mini' blDevs[0x62bc].plHdrs = {0x0004, 0x000d} -- blDevs[0x62be].devs.ir = ctrlrRf -- 'RM4c Mini' blDevs[0x62be].plHdrs = {0x0004, 0x000d} -- --[[ Other BroadLink devices: 'TC2' Touch Control: 1 to 3 gang switches; is a slave device and is typically controlled by a 'RM Pro +' using 433 MHz ]] end -- Go through all the BroadLink devices and build up a table of the children they will use local function setVeraDevices() for blId, blDevice in pairs(blDevices) do local mac = blId local BLink = blDevice.blDesc..' - ' local altId, veraDesc, dev, file = '', '', '', '' -- get the info for all the children this BroadLink device will need for k, v in pairs(blDevs[blDevice.blDeviceType].devs) do altId = mac..'_'..k debug('k = '..k) -- get the function for this vera device func = v -- ready for relays dev, file = DEV.BINARY_LIGHT, FILE.BINARY_LIGHT if (k=='rly1') then veraDesc = BLink..'relay 1' elseif (k=='rly2') then veraDesc = BLink..'relay 2' elseif (k=='rly3') then veraDesc = BLink..'relay 3' elseif (k=='rly4') then veraDesc = BLink..'relay 4' elseif (k=='humidity') then veraDesc = BLink..'humidity 1' dev, file = DEV.HUMIDITY_SENSOR, FILE.HUMIDITY_SENSOR elseif (k=='ir') then veraDesc = BLink..'IR 1' dev, file = DEV.IR_TRANSMITTER, FILE.IR_TRANSMITTER elseif (k=='lightLevel') then veraDesc = BLink..'light level 1' dev, file = DEV.LIGHT_SENSOR, FILE.LIGHT_SENSOR elseif (k=='noise') then veraDesc = BLink..'noise level 1' dev, file = DEV.GENERIC_SENSOR, FILE.GENERIC_SENSOR -- elseif (k=='rf315') then -- veraDesc = BLink..'RF 315 1' -- dev, file = DEV.IR_TRANSMITTER, FILE.IR_TRANSMITTER -- elseif (k=='rf433') then -- veraDesc = BLink..'RF 433 1' -- dev, file = DEV.IR_TRANSMITTER, FILE.IR_TRANSMITTER elseif (k=='temp') then veraDesc = BLink..'temperature 1' dev, file = DEV.TEMPERATURE_SENSOR, FILE.TEMPERATURE_SENSOR elseif (k=='voc') then veraDesc = BLink..'light level 1' dev, file = DEV.GENERIC_SENSOR, FILE.GENERIC_SENSOR else altId = nil debug('k = '..k..' has no associated code at this time') end if (altId) then veraDevices[altId] = { blId = mac, -- we use the BroadLink device mac address to identify the child's parent hardware veraDesc = veraDesc, veraDevice = dev, veraFile = file, veraFunc = func -- veraId is set up once the child is created } debug(altId) debug(veraDevices[altId].blId) debug(veraDevices[altId].veraDesc) debug(veraDevices[altId].veraDevice) debug(veraDevices[altId].veraFile) debug(veraDevices[altId].veraFunc) end end end end -- Poll the BroadLink device for data. Function needs to be global. function pollBroadLinkDevices() if (m_PollEnable ~= '1') then return end -- poll sensors: temperature, humidity, etc contained in all the discovered BroadLink devices -- altId (that is k) is of the form: 'xx:xx:xx:xx:xx:xx_temp' where the xxs make up the mac address for k,v in pairs(veraDevices) do local blId = v.blId local veraId = v.veraId local veraDevice = v.veraDevice local veraFunc = v.veraFunc -- look up the function to be used for this device -- make sure we have a BroadLink device and an associated function and then go poll all the sensors found if (blId and veraFunc and veraDevice) then if (veraDevice == DEV.DOOR_SENSOR) then updateVariable('Tripped', veraFunc(blId), SID.DOOR_SENSOR, veraId) elseif (veraDevice == DEV.GENERIC_SENSOR) then updateVariable('CurrentLevel', veraFunc(blId), SID.GENERIC_SENSOR, veraId) elseif (veraDevice == DEV.HUMIDITY_SENSOR) then updateVariable('CurrentLevel', veraFunc(blId), SID.HUMIDITY_SENSOR, veraId) elseif (veraDevice == DEV.LIGHT_SENSOR) then updateVariable('CurrentLevel', veraFunc(blId), SID.LIGHT_SENSOR, veraId) elseif (veraDevice == DEV.MOTION_SENSOR) then updateVariable('Tripped', veraFunc(blId), SID.MOTION_SENSOR, veraId) elseif (veraDevice == DEV.SMOKE_SENSOR) then updateVariable('Tripped', veraFunc(blId), SID.SMOKE_SENSOR, veraId) elseif (veraDevice == DEV.TEMPERATURE_SENSOR) then -- This is a pretty crude correction and is only likely to be close to accurate at one particular -- temperature. Get the temperature and temperature correction factor and update the result. local offsetStr = luup.variable_get(SID.TEMPERATURE_SENSOR, 'TemperatureOffset', veraId) local temperatureOffset = tonumber(offsetStr) if not temperatureOffset then temperatureOffset = 0 end local ok, temperature = veraFunc(blId) if (ok) then updateVariable('CurrentTemperature', temperature + temperatureOffset, SID.TEMPERATURE_SENSOR, veraId) else debug(v.veraDesc..': failed to get temperature. Is the device offline?',2) end -- add in more sensors here with additional elseif else -- update the status of any relays -- altId (that is k) contains the relay number to use (if any) local _, _, relay = string.find(k, 'rly(%d)') relay = tonumber(relay) if (relay) then updateStatus(blId, veraId, relay) else debug(v.veraDesc..': device is not a sensor or if a sensor; is not coded for') debug(string.format('%s: veraId: %d, blId: %s, altId: %s', v.veraDesc, veraId, blId, k)) debug(v.veraDesc..': '..veraDevice) end end end end --[[ -- do we want this? local timeStamp = os.time() updateVariable('LastUpdate', timeStamp, SID.HA) local timeFormat = '%F %X' debug('Last update: '..os.date(timeFormat, timeStamp)) timeFormat = '%H:%M' updateVariable('LastUpdateHr', os.date(timeFormat, timeStamp)) ]] -- get the info contained in all the BroadLink devices every poll interval luup.call_delay('pollBroadLinkDevices', m_PollInterval) end -- User service: polling on off local function polling(pollEnable) if (not ((pollEnable == '0') or (pollEnable == '1'))) then return end m_PollEnable = pollEnable updateVariable('PollEnable', m_PollEnable) end -- OK lets do it function luaStartUp(lul_device) THIS_LUL_DEVICE = lul_device debug('Initialising plugin: '..PLUGIN_NAME) -- Lua ver 5.1 does not have bit functions, whereas ver 5.2 and above do. Not -- that this matters in this code but it's nice to know if anything changes. debug('Using: '.._VERSION) -- returns the string: 'Lua x.y' -- set up some defaults: updateVariable('PluginVersion', PLUGIN_VERSION) -- set up some defaults: local debugEnabled = luup.variable_get(PLUGIN_SID, 'DebugEnabled', THIS_LUL_DEVICE) if ((debugEnabled == nil) or (debugEnabled == '')) then debugEnabled = '0' updateVariable('DebugEnabled', debugEnabled) end DEBUG_MODE = (debugEnabled == '1') local pluginEnabled = luup.variable_get(PLUGIN_SID, 'PluginEnabled', THIS_LUL_DEVICE) if ((pluginEnabled == nil) or (pluginEnabled == '')) then pluginEnabled = '1' updateVariable('PluginEnabled', pluginEnabled) end m_json = loadJsonModule() if (not m_json) then return false, 'No JSON module found', PLUGIN_NAME end local broadLinkDevices = luup.variable_get(PLUGIN_SID, 'BroadLinkDevices', THIS_LUL_DEVICE) if ((broadLinkDevices == nil) or (broadLinkDevices == '')) then broadLinkDevices = '{}' updateVariable('BroadLinkDevices', broadLinkDevices) end local pollEnable = luup.variable_get(PLUGIN_SID, 'PollEnable', THIS_LUL_DEVICE) if ((pollEnable == nil) or (pollEnable == '')) then -- turn the polling on m_PollEnable = '1' polling(m_PollEnable) else m_PollEnable = pollEnable end -- don't allow polling any faster than five minutes local theInterval = tonumber(pollInterval) if ((theInterval == nil) or (theInterval < FIVE_MIN_IN_SECS)) then m_PollInterval = FIVE_MIN_IN_SECS updateVariable('PollInterval', tostring(FIVE_MIN_IN_SECS)) else m_PollInterval = theInterval end -- Works ok but is not called any where; testing only -- However it would be called like this, in this sort of framework. -- sendPairingMsg() setDeviceConfiguration() OUR_IP = getOurIPaddress() -- We need the history of past online devices. If a device goes offline temporarily, it -- will still be possible to retain its children during the append process further below. blDevices = m_json.decode(broadLinkDevices) if (not blDevices) then debug('JSON decode error: blDevices is nil') blDevices = {} end -- What's out there? Build and/or update the blDevices table. broadcastDiscoverDevicesMsg() -- Now that all the BroadLink devices that are actually online have been -- discovered, we can go get their authorisation info: blKey & blInternalId -- Offline devices will just time out and be logged as such. getAuthorisation() -- go through all the BroadLink devices and build up a table of the children they will use setVeraDevices() -- Note that only the online devices get updated. Offline devices rely on the previous online history loaded -- from the persistent json varaible. This also updates blKey & blInternalId discovered during authorisation. updateVariable('BroadLinkDevices', m_json.encode(blDevices)) -- make a child for each device found as part of each BroadLink device local child_devices = luup.chdev.start(THIS_LUL_DEVICE) --[[ Add child devices: The child D_*.xml files each specify a "serviceList" with a link to the associated S_*.xml file containing the actionList. With the parent "handleChildren" set to one, the child serviceList is handled by the parent. It contains the link to the "implementation file: I_*.xml" holding the interfaces and the run time code or a link to that code. If the parent device specifies: 1 then child devices do not need an implementation file. If the Device file includes: I_PluginName.xml then the implementation file does not need to be additionally specified when the child is created. The device file references the service files (S_) and gives each service a serviceType and a serviceId. The serviceType what defines the standard UPnP service. But since it's possible to have multiple instances of a given service, so each needs a unique serviceId. Also see: https://community.getvera.com/t/plugin-and-childs/189244/6 https://community.getvera.com/t/variables-scope-and-visibility/164611/10 ]] for k,v in pairs(veraDevices) do luup.chdev.append( THIS_LUL_DEVICE, child_devices, k, -- altid v.veraDesc, -- name v.veraDevice, -- device type v.veraFile, -- device filename '', -- implementation filename '', -- parameters false) -- embedded end -- if any of the children specified above are brand new, changed or deleted, then this code will result in a Luup engine restart luup.chdev.sync(THIS_LUL_DEVICE, child_devices) -- find all of the children of this parent device -- and then for each child record its Vera id -- if a device is off line, blDevices contains sufficient information to keep the children in place for deviceID,v in pairs(luup.devices) do if (v.device_num_parent == THIS_LUL_DEVICE) then -- for each vera child device we record its vera id -- where v.id is the altId used to index our children veraDevices[v.id].veraId = deviceID -- The user may have changed the original Vera device description as seen in the UI. -- So keep track of any users changes to the device descriptions. veraDevices[v.id].veraDesc = v.description -- for temperature devices, we'll provide a very crude temperature correction facility if (veraDevices[v.id].veraDevice == DEV.TEMPERATURE_SENSOR) then local temperatureOffset = luup.variable_get(SID.TEMPERATURE_SENSOR, 'TemperatureOffset', deviceID) if ((temperatureOffset == nil) or (temperatureOffset == '')) then updateVariable('TemperatureOffset', '0', SID.TEMPERATURE_SENSOR, deviceID) end end end end -- delay so that the first poll occurs delay interval after start up local INITIAL_POLL_INTERVAL_SECS = 85 luup.call_delay('pollBroadLinkDevices', INITIAL_POLL_INTERVAL_SECS) -- required for UI7. UI5 uses true or false for the passed parameter. -- UI7 uses 0 or 1 or 2 for the parameter. This works for both UI5 and UI7 luup.set_failure(false) return true, 'All OK', PLUGIN_NAME end