├── .prettierrc ├── README.md ├── login.py ├── drivers ├── meross-smart-plug-mini.groovy ├── meross-smart-wifi-garage-door-opener.groovy ├── meross-2-channel-smart-plugs.groovy └── meross-smart-dimmer-plug.groovy ├── login.js ├── apps └── meross_app.groovy └── LICENSE /.prettierrc: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Hubitat Meross 4 | 5 | **[BETA]** Unofficial Hubitat Drivers for Meross Smart Devices 6 | 7 | 8 | 9 | ## Currently Supports 10 | 11 | - MSS120 - Smart WiFi Plug (2 Channel) 12 | - MSS620 - Smart WiFi Indoor/Outdoor Plug (2 Channel) 13 | - MSS110 - Smart Plug Mini 14 | - MSG200 - Smart WiFi Garage Door Opener 15 | - MDP100 - Smart WiFi Indoor/Outdoor Dimmer Plug 16 | 17 | ## Authorization & Configuration 18 | 19 | 1. If you're setting this plug up fresh, make sure you go through the 20 | typical Meross app for initial setup. 21 | 22 | 2. You will also have to obtain some information that the Meross mobile 23 | app uses in its HTTP request headers. See [How To Get Credentials](https://github.com/donavanbecker/homebridge-meross/wiki/Getting-Credentials) for more details. 24 | 25 | ## Generating a key for MSG200 - Smart WiFi Garage Door Opener 26 | 27 | Included in the repo is a python scripts for generating keys for the latest garage door firmware taken from [meross-api](https://github.com/bapirex/meross-api/blob/master/login.py). Run the script with python and copy the values into your hubitat config 28 | 29 | ``` 30 | python3 ./login.py 31 | ``` 32 | 33 | If you are missing modules you can install them with pip: 34 | 35 | ```sh 36 | python3 -m pip install requests 37 | ``` 38 | 39 | ## Generating a key using Chrome 40 | 41 | If you're not familiar with python you can also generate a key using chrome or any other modern browser using the [included JS script](./login.js). 42 | 43 | 1. Go to https://iot.meross.com/v1/Auth/signIn in your browser 44 | 1. Right click on the page, click inspect and locate the console in Chrome Dev Tools 45 | 1. Paste the script into the console 46 | 1. Update USERNAME and PASSWORD strings in the last line. Hit enter. 47 | 48 | ## Prior Art 49 | 50 | Based off [homebridge-meross](https://github.com/donavanbecker/homebridge-meross) 51 | -------------------------------------------------------------------------------- /login.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import string 4 | import random 5 | import requests 6 | import time 7 | 8 | def rand_gen(size, chars=string.ascii_lowercase + string.digits): 9 | return str(''.join(random.choice(chars) for _ in range(size))) 10 | 11 | def msg_id(unix_time): 12 | concat_string = '{}{}'.format(rand_gen(16), unix_time) 13 | final_md5 = hashlib.md5(concat_string.encode('utf-8')).hexdigest() 14 | return str(final_md5) 15 | 16 | def get_unix_time(): 17 | current_time = int(time.time()) 18 | return current_time 19 | 20 | def get_key(username, password, uts): 21 | 22 | nonce = rand_gen 23 | unix_time = uts 24 | 25 | param = '{{"email":"{}","password":"{}"}}'.format(username, password) 26 | encoded_param = base64.standard_b64encode(param.encode('utf8')) 27 | 28 | concat_sign = '{}{}{}{}'.format('23x17ahWarFH6w29', unix_time, nonce, encoded_param.decode("utf-8")) 29 | sign = hashlib.md5(concat_sign.encode('utf-8')).hexdigest() 30 | 31 | headers = { 32 | 'content-type': 'application/x-www-form-urlencoded', 33 | } 34 | data = { 35 | 'params': encoded_param, 36 | 'sign': sign, 37 | 'timestamp': unix_time, 38 | 'nonce': nonce 39 | } 40 | response = requests.post('https://iot.meross.com/v1/Auth/login', headers=headers, data=data) 41 | key = response.json()['data']['key'] 42 | userid = response.json()['data']['userid'] 43 | token = response.json()['data']['token'] 44 | return str(key), str(userid), str(token) 45 | 46 | def signing_key(message_id, key, uts): 47 | concat_string = '{}{}{}'.format(message_id, key, uts) 48 | final_md5 = hashlib.md5(concat_string.encode('utf-8')).hexdigest() 49 | return str(final_md5) 50 | 51 | 52 | def login(username, password): 53 | current = get_unix_time() 54 | message_id = msg_id(current) 55 | 56 | key, userid, token = get_key(username, password, current) 57 | sign = signing_key(message_id,key, current) 58 | 59 | print("{} {}".format("userId:", userid)) 60 | print("{} {}".format("key:", key)) 61 | print("{} {}".format("token:", token)) 62 | print("{} {}".format("messageId:", message_id)) 63 | print("{} {}".format("sign:", sign)) 64 | print("{} {}".format("timestamp:", current)) 65 | 66 | 67 | email = input("email: ") 68 | password = input("password: ") 69 | 70 | login(email,password) -------------------------------------------------------------------------------- /drivers/meross-smart-plug-mini.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Meross Smart Plug Mini 3 | * 4 | * Author: Daniel Tijerina 5 | * Last updated: 2021-10-06 6 | * 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 9 | * use this file except in compliance with the License. You may obtain a copy 10 | * of the License at: 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 16 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 17 | * License for the specific language governing permissions and limitations 18 | * under the License. 19 | */ 20 | 21 | import java.security.MessageDigest 22 | 23 | metadata { 24 | definition( 25 | name: 'Meross Smart Plug Mini', 26 | namespace: 'ithinkdancan', 27 | author: 'Daniel Tijerina' 28 | ) { 29 | capability 'Actuator' 30 | capability 'Switch' 31 | capability 'Refresh' 32 | capability 'Sensor' 33 | capability 'Configuration' 34 | 35 | attribute 'model', 'string' 36 | attribute 'version', 'string' 37 | } 38 | preferences { 39 | section('Device Selection') { 40 | input('deviceIp', 'text', title: 'Device IP Address', description: '', required: true, defaultValue: '') 41 | input('key', 'text', title: 'Key', description: 'Key from login.py', required: true, defaultValue: '') 42 | input('DebugLogging', 'bool', title: 'Enable debug logging', defaultValue: true) 43 | } 44 | } 45 | } 46 | 47 | def getDriverVersion() { 48 | 1 49 | } 50 | 51 | def initialize() { 52 | log 'Initializing Device' 53 | refresh() 54 | 55 | unschedule(refresh) 56 | runEvery5Minutes(refresh) 57 | } 58 | 59 | def sendCommand(int onoff, int channel) { 60 | if (!settings.deviceIp || !settings.key) { 61 | sendEvent(name: 'switch', value: 'offline', isStateChange: false) 62 | log.warn('missing setting configuration') 63 | return 64 | } 65 | try { 66 | def payloadData = getSign() 67 | def hubAction = new hubitat.device.HubAction([ 68 | method: 'POST', 69 | path: '/config', 70 | headers: [ 71 | 'HOST': settings.deviceIp, 72 | 'Content-Type': 'application/json', 73 | ], 74 | body: '{"payload":{"togglex":{"onoff":' + onoff + ',"channel":' + channel + '}},"header":{"messageId":"'+payloadData.get('MessageId')+'","method":"SET","from":"http://'+settings.deviceIp+'/config","sign":"'+payloadData.get('Sign')+'","namespace":"Appliance.Control.ToggleX","triggerSrc":"iOSLocal","timestamp":' + payloadData.get('CurrentTime') + ',"payloadVersion":1}}' 75 | ]) 76 | log hubAction 77 | runIn(0, "refresh") 78 | return hubAction 79 | } catch (e) { 80 | log "runCmd hit exception ${e} on ${hubAction}" 81 | } 82 | } 83 | 84 | def refresh() { 85 | log.info('Refreshing') 86 | if (!settings.deviceIp || !settings.key) { 87 | sendEvent(name: 'switch', value: 'offline', isStateChange: false) 88 | log.warn('missing setting configuration') 89 | return 90 | } 91 | try { 92 | def payloadData = getSign() 93 | 94 | log.info('Refreshing') 95 | 96 | def hubAction = new hubitat.device.HubAction([ 97 | method: 'POST', 98 | path: '/config', 99 | headers: [ 100 | 'HOST': settings.deviceIp, 101 | 'Content-Type': 'application/json', 102 | ], 103 | body: '{"payload":{},"header":{"messageId":"'+payloadData.get('MessageId')+'","method":"GET","from":"http://'+settings.deviceIp+'/subscribe","sign":"'+ payloadData.get('Sign') +'","namespace": "Appliance.System.All","triggerSrc":"AndroidLocal","timestamp":' + payloadData.get('CurrentTime') + ',"payloadVersion":1}}' 104 | ]) 105 | log.debug hubAction 106 | return hubAction 107 | } catch (Exception e) { 108 | log "runCmd hit exception ${e} on ${hubAction}" 109 | } 110 | } 111 | 112 | def on() { 113 | log.info('Turning on') 114 | return sendCommand(1, 0) 115 | } 116 | 117 | def off() { 118 | log.info('Turning off') 119 | return sendCommand(0, 0) 120 | } 121 | 122 | def updated() { 123 | log.info('Updated') 124 | initialize() 125 | } 126 | 127 | def parse(String description) { 128 | 129 | 130 | def msg = parseLanMessage(description) 131 | def body = parseJson(msg.body) 132 | 133 | if(msg.status != 200) { 134 | log.error("Request failed") 135 | return 136 | } 137 | 138 | if (body.payload.all) { 139 | def parent = body.payload.all.digest.togglex[0].onoff 140 | sendEvent(name: 'switch', value: parent ? 'on' : 'off', isStateChange: true) 141 | sendEvent(name: 'version', value: body.payload.all.system.firmware.version, isStateChange: false) 142 | sendEvent(name: 'model', value: body.payload.all.system.hardware.type, isStateChange: false) 143 | } else { 144 | log.error ("Request failed") 145 | } 146 | } 147 | 148 | def getSign(int stringLength = 16){ 149 | 150 | // Generate a random string 151 | def chars = 'abcdefghijklmnopqrstuvwxyz0123456789' 152 | def randomString = new Random().with { (0..stringLength).collect { chars[ nextInt(chars.length() ) ] }.join()} 153 | 154 | int currentTime = new Date().getTime() / 1000 155 | messageId = MessageDigest.getInstance("MD5").digest((randomString + currentTime.toString()).bytes).encodeHex().toString() 156 | sign = MessageDigest.getInstance("MD5").digest((messageId + settings.key + currentTime.toString()).bytes).encodeHex().toString() 157 | 158 | def requestData = [ 159 | CurrentTime: currentTime, 160 | MessageId: messageId, 161 | Sign: sign 162 | ] 163 | 164 | return requestData 165 | } 166 | 167 | def log(msg) { 168 | if (DebugLogging) { 169 | log.debug(msg) 170 | } 171 | } 172 | 173 | def configure() { 174 | log 'configure()' 175 | initialize() 176 | } 177 | -------------------------------------------------------------------------------- /drivers/meross-smart-wifi-garage-door-opener.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Meross Smart WiFi Garage Door Opener 3 | * 4 | * Author: Daniel Tijerina 5 | * Last updated: 2021-09-26 6 | * 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 9 | * use this file except in compliance with the License. You may obtain a copy 10 | * of the License at: 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 16 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 17 | * License for the specific language governing permissions and limitations 18 | * under the License. 19 | */ 20 | 21 | import java.security.MessageDigest 22 | 23 | metadata { 24 | definition( 25 | name: 'Meross Smart WiFi Garage Door Opener', 26 | namespace: 'ithinkdancan', 27 | author: 'Daniel Tijerina' 28 | ) { 29 | capability 'DoorControl' 30 | capability 'GarageDoorControl' 31 | capability 'Actuator' 32 | capability 'ContactSensor' 33 | capability 'Refresh' 34 | 35 | attribute 'model', 'string' 36 | attribute 'version', 'string' 37 | } 38 | preferences { 39 | section('Device Selection') { 40 | input('deviceIp', 'text', title: 'Device IP Address', description: '', required: true, defaultValue: '') 41 | input('key', 'text', title: 'Key', description: 'Required for firmware version 3.2.3 and greater', required: false, defaultValue: '') 42 | input('messageId', 'text', title: 'Message ID', description: '', required: true, defaultValue: '') 43 | input('timestamp', 'number', title: 'Timestamp', description: '', required: true, defaultValue: '') 44 | input('sign', 'text', title: 'Sign', description: '', required: true, defaultValue: '') 45 | input('uuid', 'text', title: 'UUID', description: '', required: true, defaultValue: '') 46 | input('channel', 'number', title: 'Garage Door Port', description: '', required: true, defaultValue: 1) 47 | input('garageOpenCloseTime','number',title: 'Garage Open/Close time (in seconds)', description:'', required: true, defaultValue: 5) 48 | input('DebugLogging', 'bool', title: 'Enable debug logging', defaultValue: true) 49 | } 50 | } 51 | } 52 | 53 | def getDriverVersion() { 54 | 1 55 | } 56 | 57 | def initialize() { 58 | log 'Initializing Device' 59 | refresh() 60 | 61 | unschedule(refresh) 62 | runEvery5Minutes(refresh) 63 | } 64 | 65 | def sendCommand(int open) { 66 | 67 | def currentVersion = device.currentState('version')?.value ? device.currentState('version')?.value.replace(".","").toInteger() : 0 68 | 69 | // Firmware version 3.2.3 and greater require different data for request 70 | if (!settings.deviceIp || !settings.uuid || (currentVersion >= 323 && !settings.key) || (currentVersion < 323 && (!settings.messageId || !settings.sign || !settings.timestamp))) { 71 | sendEvent(name: 'door', value: 'unknown', isStateChange: false) 72 | log.warn('missing setting configuration') 73 | return 74 | } 75 | sendEvent(name: 'door', value: open ? 'opening' : 'closing', isStateChange: true) 76 | 77 | try { 78 | def payloadData = currentVersion >= 323 ? getSign() : [MessageId: settings.messageId, Sign: settings.sign, CurrentTime: settings.timestamp] 79 | 80 | def hubAction = new hubitat.device.HubAction([ 81 | method: 'POST', 82 | path: '/config', 83 | headers: [ 84 | 'HOST': settings.deviceIp, 85 | 'Content-Type': 'application/json', 86 | ], 87 | body: '{"payload":{"state":{"open":' + open + ',"channel":' + settings.channel + ',"uuid":"' + settings.uuid + '"}},"header":{"messageId":"'+payloadData.get('MessageId')+'","method":"SET","from":"http://'+settings.deviceIp+'/config","sign":"'+payloadData.get('Sign')+'","namespace":"Appliance.GarageDoor.State","triggerSrc":"AndroidLocal","timestamp":' + payloadData.get('CurrentTime') + ',"payloadVersion":1' + ',"uuid":"' + settings.uuid + '"}}' 88 | ]) 89 | runIn(settings.garageOpenCloseTime, "refresh") 90 | return hubAction 91 | } catch (e) { 92 | log.error("runCmd hit exception ${e} on ${hubAction}") 93 | } 94 | } 95 | 96 | 97 | def refresh() { 98 | def currentVersion = device.currentState('version')?.value ? device.currentState('version')?.value.replace(".","").toInteger() : 0 99 | 100 | // Firmware version 3.2.3 and greater require different data for request 101 | if (!settings.deviceIp || !settings.uuid || (currentVersion >= 323 && !settings.key) || (currentVersion < 323 && (!settings.messageId || !settings.sign || !settings.timestamp))) { 102 | sendEvent(name: 'door', value: 'unknown', isStateChange: false) 103 | log.warn('missing setting configuration') 104 | return 105 | } 106 | try { 107 | def payloadData = currentVersion >= 323 ? getSign() : [MessageId: settings.messageId, Sign: settings.sign, CurrentTime: settings.timestamp] 108 | 109 | log.info('Refreshing') 110 | 111 | def hubAction = new hubitat.device.HubAction([ 112 | method: 'POST', 113 | path: '/config', 114 | headers: [ 115 | 'HOST': settings.deviceIp, 116 | 'Content-Type': 'application/json', 117 | ], 118 | body: '{"payload":{},"header":{"messageId":"'+payloadData.get('MessageId')+'","method":"GET","from":"http://'+settings.deviceIp+'/subscribe","sign":"'+ payloadData.get('Sign') +'","namespace": "Appliance.System.All","triggerSrc":"AndroidLocal","timestamp":' + payloadData.get('CurrentTime') + ',"payloadVersion":1}}' 119 | ]) 120 | log hubAction 121 | return hubAction 122 | } catch (Exception e) { 123 | log.debug "runCmd hit exception ${e} on ${hubAction}" 124 | } 125 | } 126 | 127 | def open() { 128 | log.info('Opening Garage') 129 | return sendCommand(1) 130 | } 131 | 132 | def close() { 133 | log.info('Closing Garage') 134 | return sendCommand(0) 135 | } 136 | 137 | def updated() { 138 | log.info('Updated') 139 | initialize() 140 | } 141 | 142 | def parse(String description) { 143 | def msg = parseLanMessage(description) 144 | def body = parseJson(msg.body) 145 | 146 | if(msg.status != 200) { 147 | log.error("Request failed") 148 | return 149 | } 150 | 151 | // Close/Open request was sent 152 | if(body.header.method == "SETACK") return 153 | 154 | if (body.payload.all) { 155 | def state = body.payload.all.digest.garageDoor[settings.channel.intValue() - 1].open 156 | sendEvent(name: 'door', value: state ? 'open' : 'closed') 157 | sendEvent(name: 'contact', value: state ? 'open' : 'closed') 158 | sendEvent(name: 'version', value: body.payload.all.system.firmware.version, isStateChange: false) 159 | sendEvent(name: 'model', value: body.payload.all.system.hardware.type, isStateChange: false) 160 | } else { 161 | //refresh() 162 | log.error ("Request failed") 163 | } 164 | } 165 | 166 | def getSign(int stringLength = 16){ 167 | 168 | // Generate a random string 169 | def chars = 'abcdefghijklmnopqrstuvwxyz0123456789' 170 | def randomString = new Random().with { (0..stringLength).collect { chars[ nextInt(chars.length() ) ] }.join()} 171 | 172 | int currentTime = new Date().getTime() / 1000 173 | messageId = MessageDigest.getInstance("MD5").digest((randomString + currentTime.toString()).bytes).encodeHex().toString() 174 | sign = MessageDigest.getInstance("MD5").digest((messageId + settings.key + currentTime.toString()).bytes).encodeHex().toString() 175 | 176 | def requestData = [ 177 | CurrentTime: currentTime, 178 | MessageId: messageId, 179 | Sign: sign 180 | ] 181 | 182 | return requestData 183 | } 184 | 185 | def log(msg) { 186 | if (DebugLogging) { 187 | log.debug(msg) 188 | } 189 | } -------------------------------------------------------------------------------- /drivers/meross-2-channel-smart-plugs.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Meross 2 Channel Smart Plug 3 | * 4 | * Author: Daniel Tijerina 5 | * Last updated: 2021-02-03 6 | * 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 9 | * use this file except in compliance with the License. You may obtain a copy 10 | * of the License at: 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 16 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 17 | * License for the specific language governing permissions and limitations 18 | * under the License. 19 | */ 20 | 21 | import java.security.MessageDigest 22 | 23 | metadata { 24 | definition( 25 | name: 'Meross 2 Channel Smart Plug', 26 | namespace: 'ithinkdancan', 27 | author: 'Daniel Tijerina' 28 | ) { 29 | capability 'Actuator' 30 | capability 'Switch' 31 | capability 'Refresh' 32 | capability 'Sensor' 33 | capability 'Configuration' 34 | 35 | command 'childOn', [[name:'Channel Number', type:'STRING', description:'Provide the channel number to turn on.']] 36 | command 'childOff', [[name:'Channel Number', type:'STRING', description:'Provide the channel number to turn off.']] 37 | command 'childRefresh', [[name:'Channel Number', type:'STRING', description:'Provide the channel number to refresh.']] 38 | command 'componentOn' 39 | command 'componentOff' 40 | command 'componentRefresh' 41 | } 42 | preferences { 43 | section('Device Selection') { 44 | input('deviceIp', 'text', title: 'Device IP Address', description: '', required: true, defaultValue: '') 45 | input('key', 'text', title: 'Key', description: 'Key from login.py', required: true, defaultValue: '') 46 | input('DebugLogging', 'bool', title: 'Enable debug logging', defaultValue: true) 47 | } 48 | } 49 | } 50 | 51 | def getDriverVersion() { 52 | 1 53 | } 54 | 55 | def sendCommand(int onoff, int channel) { 56 | if (!settings.deviceIp || !settings.key) { 57 | sendEvent(name: 'switch', value: 'offline', isStateChange: false) 58 | log.warn('missing setting configuration') 59 | return 60 | } 61 | try { 62 | def payloadData = getSign() 63 | def hubAction = new hubitat.device.HubAction([ 64 | method: 'POST', 65 | path: '/config', 66 | headers: [ 67 | 'HOST': settings.deviceIp, 68 | 'Content-Type': 'application/json', 69 | ], 70 | body: '{"payload":{"togglex":{"onoff":' + onoff + ',"channel":' + channel + '}},"header":{"messageId":"'+payloadData.get('MessageId')+'","method":"SET","from":"http://'+settings.deviceIp+'/config","sign":"'+payloadData.get('Sign')+'","namespace":"Appliance.Control.ToggleX","triggerSrc":"iOSLocal","timestamp":' + payloadData.get('CurrentTime') + ',"payloadVersion":1}}' 71 | ]) 72 | log hubAction 73 | runIn(0, "refresh") 74 | return hubAction 75 | } catch (e) { 76 | log "sendCommand - runCmd hit exception ${e} on ${hubAction}" 77 | } 78 | } 79 | 80 | def refresh() { 81 | log.info('Refreshing') 82 | if (!settings.deviceIp || !settings.key) { 83 | sendEvent(name: 'switch', value: 'offline', isStateChange: false) 84 | log.warn('missing setting configuration') 85 | return 86 | } 87 | try { 88 | def payloadData = getSign() 89 | log.info('Refreshing') 90 | 91 | def hubAction = new hubitat.device.HubAction([ 92 | method: 'POST', 93 | path: '/config', 94 | headers: [ 95 | 'HOST': settings.deviceIp, 96 | 'Content-Type': 'application/json', 97 | ], 98 | body: '{"payload":{},"header":{"messageId":"'+payloadData.get('MessageId')+'","method":"GET","from":"http://'+settings.deviceIp+'/subscribe","sign":"'+ payloadData.get('Sign') +'","namespace": "Appliance.System.All","triggerSrc":"AndroidLocal","timestamp":' + payloadData.get('CurrentTime') + ',"payloadVersion":1}}' 99 | ]) 100 | log.debug hubAction 101 | return hubAction 102 | } catch (Exception e) { 103 | log "refresh - runCmd hit exception ${e} on ${hubAction}" 104 | } 105 | } 106 | 107 | def on() { 108 | log.info('Turning on') 109 | return sendCommand(1, 0) 110 | } 111 | 112 | def off() { 113 | log.info('Turning off') 114 | return sendCommand(0, 0) 115 | } 116 | 117 | def childOn(String dni) { 118 | log.debug "childOn($dni)" 119 | return sendCommand(1, dni.toInteger()) 120 | } 121 | 122 | def childOff(String dni) { 123 | log.debug "childOff($dni)" 124 | return sendCommand(0, dni.toInteger()) 125 | } 126 | 127 | def childRefresh(String dni) { 128 | log.debug "childRefresh($dni)" 129 | refresh() 130 | } 131 | 132 | def componentOn(cd) { 133 | log "${device.label?device.label:device.name}: componentOn($cd)" 134 | return childOn(channelNumber(cd.deviceNetworkId)) 135 | } 136 | 137 | def componentOff(cd) { 138 | log "${device.label?device.label:device.name}: componentOff($cd)" 139 | return childOff(channelNumber(cd.deviceNetworkId)) 140 | } 141 | 142 | def componentRefresh(cd) { 143 | log "${device.label?device.label:device.name}: componentRefresh($cd)" 144 | return childRefresh(cd.deviceNetworkId) 145 | } 146 | 147 | def updated() { 148 | log.info('Updated') 149 | initialize() 150 | } 151 | 152 | def parse(String description) { 153 | log "description is: $description" 154 | 155 | def msg = parseLanMessage(description) 156 | def body = parseJson(msg.body) 157 | log body 158 | if (body.payload.all) { 159 | def parent = body.payload.all.digest.togglex[0].onoff 160 | sendEvent(name: 'switch', value: parent ? 'on' : 'off', isStateChange: true) 161 | sendEvent(name: 'version', value: body.payload.all.system.firmware.version, isStateChange: false) 162 | sendEvent(name: 'model', value: body.payload.all.system.hardware.type, isStateChange: false) 163 | 164 | childDevices.each { 165 | childDevice -> 166 | def channel = channelNumber(childDevice.deviceNetworkId) as Integer 167 | def childState = body.payload.all.digest.togglex[channel].onoff 168 | log "channel $channel: $childState" 169 | childDevice.sendEvent(name: 'switch', value: childState ? 'on' : 'off') 170 | } 171 | } else { 172 | refresh() 173 | } 174 | } 175 | 176 | def getSign(int stringLength = 16){ 177 | // Generate a random string 178 | def chars = 'abcdefghijklmnopqrstuvwxyz0123456789' 179 | def randomString = new Random().with { (0..stringLength).collect { chars[ nextInt(chars.length() ) ] }.join()} 180 | int currentTime = new Date().getTime() / 1000 181 | messageId = MessageDigest.getInstance("MD5").digest((randomString + currentTime.toString()).bytes).encodeHex().toString() 182 | sign = MessageDigest.getInstance("MD5").digest((messageId + settings.key + currentTime.toString()).bytes).encodeHex().toString() 183 | def requestData = [ 184 | CurrentTime: currentTime, 185 | MessageId: messageId, 186 | Sign: sign 187 | ] 188 | return requestData 189 | } 190 | 191 | def log(msg) { 192 | if (DebugLogging) { 193 | log.debug(msg) 194 | } 195 | } 196 | 197 | def initialize() { 198 | log 'initialize()' 199 | if (!childDevices) { 200 | createChildDevices() 201 | } 202 | refresh() 203 | 204 | log 'scheduling()' 205 | unschedule(refresh) 206 | runEvery1Minute(refresh) 207 | } 208 | 209 | def configure() { 210 | log 'configure()' 211 | initialize() 212 | } 213 | 214 | private channelNumber(String dni) { 215 | dni.split('-ep')[-1] 216 | } 217 | 218 | private void createChildDevices() { 219 | state.oldLabel = device.label 220 | for (i in 1..2) { 221 | addChildDevice('hubitat', 'Generic Component Switch', "${device.deviceNetworkId}-ep${i}", [completedSetup: true, label: "${device.displayName} (CH${i})", 222 | isComponent: false, componentName: "ep$i", componentLabel: "Channel $i" 223 | ]) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /login.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ! 1: Goto https://iot.meross.com/v1/Auth/signIn 3 | * ! 2: right click the page, select inspect, goto the dev console 4 | * ! 3: paste in this code, replace your username and pass, hit enter 5 | */ 6 | function md5cycle(x, k) { 7 | var a = x[0], 8 | b = x[1], 9 | c = x[2], 10 | d = x[3]; 11 | 12 | a = ff(a, b, c, d, k[0], 7, -680876936); 13 | d = ff(d, a, b, c, k[1], 12, -389564586); 14 | c = ff(c, d, a, b, k[2], 17, 606105819); 15 | b = ff(b, c, d, a, k[3], 22, -1044525330); 16 | a = ff(a, b, c, d, k[4], 7, -176418897); 17 | d = ff(d, a, b, c, k[5], 12, 1200080426); 18 | c = ff(c, d, a, b, k[6], 17, -1473231341); 19 | b = ff(b, c, d, a, k[7], 22, -45705983); 20 | a = ff(a, b, c, d, k[8], 7, 1770035416); 21 | d = ff(d, a, b, c, k[9], 12, -1958414417); 22 | c = ff(c, d, a, b, k[10], 17, -42063); 23 | b = ff(b, c, d, a, k[11], 22, -1990404162); 24 | a = ff(a, b, c, d, k[12], 7, 1804603682); 25 | d = ff(d, a, b, c, k[13], 12, -40341101); 26 | c = ff(c, d, a, b, k[14], 17, -1502002290); 27 | b = ff(b, c, d, a, k[15], 22, 1236535329); 28 | 29 | a = gg(a, b, c, d, k[1], 5, -165796510); 30 | d = gg(d, a, b, c, k[6], 9, -1069501632); 31 | c = gg(c, d, a, b, k[11], 14, 643717713); 32 | b = gg(b, c, d, a, k[0], 20, -373897302); 33 | a = gg(a, b, c, d, k[5], 5, -701558691); 34 | d = gg(d, a, b, c, k[10], 9, 38016083); 35 | c = gg(c, d, a, b, k[15], 14, -660478335); 36 | b = gg(b, c, d, a, k[4], 20, -405537848); 37 | a = gg(a, b, c, d, k[9], 5, 568446438); 38 | d = gg(d, a, b, c, k[14], 9, -1019803690); 39 | c = gg(c, d, a, b, k[3], 14, -187363961); 40 | b = gg(b, c, d, a, k[8], 20, 1163531501); 41 | a = gg(a, b, c, d, k[13], 5, -1444681467); 42 | d = gg(d, a, b, c, k[2], 9, -51403784); 43 | c = gg(c, d, a, b, k[7], 14, 1735328473); 44 | b = gg(b, c, d, a, k[12], 20, -1926607734); 45 | 46 | a = hh(a, b, c, d, k[5], 4, -378558); 47 | d = hh(d, a, b, c, k[8], 11, -2022574463); 48 | c = hh(c, d, a, b, k[11], 16, 1839030562); 49 | b = hh(b, c, d, a, k[14], 23, -35309556); 50 | a = hh(a, b, c, d, k[1], 4, -1530992060); 51 | d = hh(d, a, b, c, k[4], 11, 1272893353); 52 | c = hh(c, d, a, b, k[7], 16, -155497632); 53 | b = hh(b, c, d, a, k[10], 23, -1094730640); 54 | a = hh(a, b, c, d, k[13], 4, 681279174); 55 | d = hh(d, a, b, c, k[0], 11, -358537222); 56 | c = hh(c, d, a, b, k[3], 16, -722521979); 57 | b = hh(b, c, d, a, k[6], 23, 76029189); 58 | a = hh(a, b, c, d, k[9], 4, -640364487); 59 | d = hh(d, a, b, c, k[12], 11, -421815835); 60 | c = hh(c, d, a, b, k[15], 16, 530742520); 61 | b = hh(b, c, d, a, k[2], 23, -995338651); 62 | 63 | a = ii(a, b, c, d, k[0], 6, -198630844); 64 | d = ii(d, a, b, c, k[7], 10, 1126891415); 65 | c = ii(c, d, a, b, k[14], 15, -1416354905); 66 | b = ii(b, c, d, a, k[5], 21, -57434055); 67 | a = ii(a, b, c, d, k[12], 6, 1700485571); 68 | d = ii(d, a, b, c, k[3], 10, -1894986606); 69 | c = ii(c, d, a, b, k[10], 15, -1051523); 70 | b = ii(b, c, d, a, k[1], 21, -2054922799); 71 | a = ii(a, b, c, d, k[8], 6, 1873313359); 72 | d = ii(d, a, b, c, k[15], 10, -30611744); 73 | c = ii(c, d, a, b, k[6], 15, -1560198380); 74 | b = ii(b, c, d, a, k[13], 21, 1309151649); 75 | a = ii(a, b, c, d, k[4], 6, -145523070); 76 | d = ii(d, a, b, c, k[11], 10, -1120210379); 77 | c = ii(c, d, a, b, k[2], 15, 718787259); 78 | b = ii(b, c, d, a, k[9], 21, -343485551); 79 | 80 | x[0] = add32(a, x[0]); 81 | x[1] = add32(b, x[1]); 82 | x[2] = add32(c, x[2]); 83 | x[3] = add32(d, x[3]); 84 | } 85 | 86 | function cmn(q, a, b, x, s, t) { 87 | a = add32(add32(a, q), add32(x, t)); 88 | return add32((a << s) | (a >>> (32 - s)), b); 89 | } 90 | 91 | function ff(a, b, c, d, x, s, t) { 92 | return cmn((b & c) | (~b & d), a, b, x, s, t); 93 | } 94 | 95 | function gg(a, b, c, d, x, s, t) { 96 | return cmn((b & d) | (c & ~d), a, b, x, s, t); 97 | } 98 | 99 | function hh(a, b, c, d, x, s, t) { 100 | return cmn(b ^ c ^ d, a, b, x, s, t); 101 | } 102 | 103 | function ii(a, b, c, d, x, s, t) { 104 | return cmn(c ^ (b | ~d), a, b, x, s, t); 105 | } 106 | 107 | function md51(s) { 108 | txt = ""; 109 | var n = s.length, 110 | state = [1732584193, -271733879, -1732584194, 271733878], 111 | i; 112 | for (i = 64; i <= s.length; i += 64) { 113 | md5cycle(state, md5blk(s.substring(i - 64, i))); 114 | } 115 | s = s.substring(i - 64); 116 | var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; 117 | for (i = 0; i < s.length; i++) 118 | tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3); 119 | tail[i >> 2] |= 0x80 << (i % 4 << 3); 120 | if (i > 55) { 121 | md5cycle(state, tail); 122 | for (i = 0; i < 16; i++) tail[i] = 0; 123 | } 124 | tail[14] = n * 8; 125 | md5cycle(state, tail); 126 | return state; 127 | } 128 | 129 | /* there needs to be support for Unicode here, 130 | * unless we pretend that we can redefine the MD-5 131 | * algorithm for multi-byte characters (perhaps 132 | * by adding every four 16-bit characters and 133 | * shortening the sum to 32 bits). Otherwise 134 | * I suggest performing MD-5 as if every character 135 | * was two bytes--e.g., 0040 0025 = @%--but then 136 | * how will an ordinary MD-5 sum be matched? 137 | * There is no way to standardize text to something 138 | * like UTF-8 before transformation; speed cost is 139 | * utterly prohibitive. The JavaScript standard 140 | * itself needs to look at this: it should start 141 | * providing access to strings as preformed UTF-8 142 | * 8-bit unsigned value arrays. 143 | */ 144 | function md5blk(s) { 145 | /* I figured global was faster. */ 146 | var md5blks = [], 147 | i; /* Andy King said do it this way. */ 148 | for (i = 0; i < 64; i += 4) { 149 | md5blks[i >> 2] = 150 | s.charCodeAt(i) + 151 | (s.charCodeAt(i + 1) << 8) + 152 | (s.charCodeAt(i + 2) << 16) + 153 | (s.charCodeAt(i + 3) << 24); 154 | } 155 | return md5blks; 156 | } 157 | 158 | var hex_chr = "0123456789abcdef".split(""); 159 | 160 | function rhex(n) { 161 | var s = "", 162 | j = 0; 163 | for (; j < 4; j++) 164 | s += hex_chr[(n >> (j * 8 + 4)) & 0x0f] + hex_chr[(n >> (j * 8)) & 0x0f]; 165 | return s; 166 | } 167 | 168 | function hex(x) { 169 | for (var i = 0; i < x.length; i++) x[i] = rhex(x[i]); 170 | return x.join(""); 171 | } 172 | 173 | function md5(s) { 174 | return hex(md51(s)); 175 | } 176 | 177 | /* this function is much faster, 178 | so if possible we use it. Some IEs 179 | are the only ones I know of that 180 | need the idiotic second function, 181 | generated by an if clause. */ 182 | 183 | function add32(a, b) { 184 | return (a + b) & 0xffffffff; 185 | } 186 | 187 | if (md5("hello") != "5d41402abc4b2a76b9719d911017c592") { 188 | function add32(x, y) { 189 | var lsw = (x & 0xffff) + (y & 0xffff), 190 | msw = (x >> 16) + (y >> 16) + (lsw >> 16); 191 | return (msw << 16) | (lsw & 0xffff); 192 | } 193 | } 194 | 195 | var randomString = function (length) { 196 | var text = ""; 197 | var possible = 198 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 199 | for (var i = 0; i < length; i++) { 200 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 201 | } 202 | return text; 203 | }; 204 | 205 | (async function test(username, password) { 206 | const nonce = randomString(16); 207 | const unix_time = Math.floor(new Date().getTime() / 1000); 208 | 209 | const param = JSON.stringify({ 210 | email: username, 211 | password: password, 212 | }); 213 | const encoded_param = btoa(param); 214 | 215 | concat_sign = ["23x17ahWarFH6w29", unix_time, nonce, encoded_param].join(""); 216 | const sign = md5(concat_sign); 217 | 218 | const data = { 219 | params: encoded_param, 220 | sign: sign, 221 | timestamp: parseInt(unix_time, 10), 222 | nonce: nonce, 223 | }; 224 | 225 | try { 226 | const response = await fetch("https://iot.meross.com/v1/Auth/signIn", { 227 | headers: { 228 | "Content-Type": "application/x-www-form-urlencoded", 229 | }, 230 | method: "POST", 231 | body: new URLSearchParams(data), 232 | }); 233 | 234 | const result = await response.json(); 235 | const { key, userid, token } = result.data; 236 | const messageId = md5([randomString(16, unix_time)].join("")); 237 | const sign = md5([randomString(messageId, key, unix_time)].join("")); 238 | console.log({ 239 | userid, 240 | key, 241 | token, 242 | messageId: messageId, 243 | sign: sign, 244 | timestamp: unix_time, 245 | }); 246 | } catch (error) { 247 | console.log({ error }); 248 | } 249 | })("USERNAME", "PASSWORD"); 250 | -------------------------------------------------------------------------------- /apps/meross_app.groovy: -------------------------------------------------------------------------------- 1 | import groovy.json.* 2 | import java.net.URLEncoder 3 | import java.security.MessageDigest 4 | 5 | def appVersion() { return "0.1.0" } 6 | 7 | definition( 8 | name: "Meross Garage Door Manager", 9 | namespace: "ajardolino3", 10 | author: "Art Ardolino", 11 | description: "Manages the addition and removal of Meross Garage Door Devices", 12 | category: "Bluetooth", 13 | iconUrl: "", 14 | iconX2Url: "", 15 | singleInstance: true, 16 | importUrl: "" 17 | ) 18 | 19 | preferences() { 20 | page name: "mainPage" 21 | page name: "addGarageDoorStep1" 22 | page name: "addGarageDoorStep2" 23 | page name: "addGarageDoorStep3" 24 | page name: "addGarageDoorStep4" 25 | page name: "listGarageDoorPage" 26 | } 27 | 28 | def mainPage() { 29 | return dynamicPage(name: "mainPage", title: "Meross Garage Door Manager", uninstall: true, install: true) { 30 | section(){ 31 | paragraph("The Meross Garage Door Manager assists with the configration of Meross Garage Door Opener devices.") 32 | href "addGarageDoorStep1", title: "Add New Garage Doors", description: "Adds new garage door devices." 33 | href "listGarageDoorPage", title: "List Garage Doors", description: "Lists added garage door devices." 34 | input "debugLog", "bool", title: "Enable debug logging", submitOnChange: true, defaultValue: false 35 | } 36 | } 37 | } 38 | 39 | def listGarageDoorPage() { 40 | def devices = getChildDevices() 41 | def message = "" 42 | devices.each{ device -> 43 | message += "\t${device.label} (ID: ${device.getDeviceNetworkId()})\n" 44 | } 45 | return dynamicPage(name: "listGarageDoorPage", title: "List Garage Doors", install: false, nextPage: mainPage) { 46 | section() { 47 | paragraph "The following devices were added using the 'Add New Garage Doors' feature." 48 | paragraph message 49 | } 50 | } 51 | } 52 | 53 | def addGarageDoorStep1() { 54 | def newBeacons = [:] 55 | state.beacons.each { beacon -> 56 | def isChild = getChildDevice(beacon.value.dni) 57 | if (!isChild && beacon.value.present) { 58 | newBeacons["${beacon.value.dni}"] = "${beacon.value.type}: ${beacon.value.dni}" 59 | } 60 | } 61 | 62 | return dynamicPage(name: "addGarageDoorStep1", title: "Add New Garage Doors (Step 1)", install: false, nextPage: addGarageDoorStep2) { 63 | section(){ 64 | input "merossUsername", "string", required: true, title: "Enter your Meross username" 65 | input "merossPassword", "password", required: true, title: "Enter your Meross password" 66 | input "merossIP", "string", required: true, title: "Enter the IP address of your Meross device" 67 | } 68 | } 69 | } 70 | 71 | def addGarageDoorStep2() { 72 | def response = loginMeross(merossUsername,merossPassword) 73 | if(response.code == 200) { 74 | state.data = getMerossData(response.token) 75 | state.merossKey = response.key 76 | def devices = [:] 77 | state.data.each{ device -> 78 | devices["${device.uuid}"] = device.devName 79 | } 80 | return dynamicPage(name: "addGarageDoorStep2", title: "Add New Garage Doors (Step 2)", install: false, nextPage: addGarageDoorStep3) { 81 | section(){ 82 | input ("selectedDevice", "enum", 83 | required: true, 84 | multiple: false, 85 | title: "Select a device to add (${devices.size() ?: 0} devices detected)", 86 | description: "Use the dropdown to select a device.", 87 | options: devices) 88 | } 89 | } 90 | } 91 | else { 92 | return dynamicPage(name: "addGarageDoorStep2", title: "Login Failed", install: false, nextPage: mainPage) { 93 | section(){ 94 | paragraph response.error 95 | } 96 | } 97 | } 98 | } 99 | 100 | def addGarageDoorStep3() { 101 | def doors = [:] 102 | state.data.each { device -> 103 | if(device.uuid == selectedDevice) { 104 | for(i=1; i 129 | if(device.uuid == selectedDevice) { 130 | for(i=1; i 139 | def door = doors[door_index] 140 | logDebug("index: " + door_index + ", door:" + door) 141 | def dni = selectedDevice + ":" + door_index 142 | def isChild = getChildDevice(dni) 143 | def success = false 144 | def err = "" 145 | if (!isChild) { 146 | try { 147 | isChild = addChildDevice("ithinkdancan", "Meross Smart WiFi Garage Door Opener", dni, ["label": door.devName]) 148 | isChild.updateSetting("deviceIp", merossIP) 149 | isChild.updateSetting("channel", Integer.parseInt(door_index)) 150 | isChild.updateSetting("uuid", selectedDevice) 151 | isChild.updateSetting("key", state.merossKey) 152 | isChild.updateSetting("messageId", "N/A") 153 | isChild.updateSetting("sign", "N/A") 154 | isChild.updateSetting("timestamp", 0) 155 | success = true 156 | } 157 | catch(exception) { 158 | err = exception 159 | } 160 | } 161 | if(success) { 162 | message += "New door added successfully (" + door.devName + ").
" 163 | } else { 164 | message += "Unable to add door: " + err + "
"; 165 | } 166 | } 167 | app?.removeSetting("selectedDevice") 168 | app?.removeSetting("selectedDoors") 169 | 170 | return dynamicPage(name:"addGarageDoorStep4", 171 | title: "Add Garage Door Status", 172 | nextPage: mainPage, 173 | install: false) { 174 | section() { 175 | paragraph message 176 | } 177 | } 178 | } 179 | 180 | def installed() { 181 | // called when app is installed 182 | } 183 | 184 | def updated() { 185 | // called when settings are updated 186 | } 187 | 188 | def uninstalled() { 189 | // called when app is uninstalled 190 | } 191 | 192 | def generator(alphabet,n) { 193 | return new Random().with { 194 | (1..n).collect { alphabet[ nextInt( alphabet.length() ) ] }.join() 195 | } 196 | } 197 | 198 | def getMerossData(token) { 199 | def nonce = generator( (('A'..'Z')+('0'..'9')).join(), 16 ) 200 | def unix_time = (Integer)Math.floor(new Date().getTime() / 1000); 201 | 202 | def param = "e30="; 203 | def encoded_param = param; 204 | 205 | def concat_sign = ["23x17ahWarFH6w29", unix_time, nonce, encoded_param].join("") 206 | MessageDigest digest = MessageDigest.getInstance('MD5') 207 | digest.update(concat_sign.bytes, 0, concat_sign.length()) 208 | def sign = new BigInteger(1, digest.digest()).toString(16) 209 | 210 | def data = [:] 211 | data.params = encoded_param 212 | data.sign = sign 213 | data.timestamp = unix_time 214 | data.nonce = nonce 215 | def json = JsonOutput.toJson(data) 216 | 217 | def commandParams = [ 218 | uri: "https://iot.meross.com/v1/Device/devList", 219 | contentType: "application/json", 220 | requestContentType: 'application/json', 221 | headers: ['Authorization':'Basic ' + token], 222 | body : data 223 | ] 224 | def respData 225 | try { 226 | httpPostJson(commandParams) {resp -> 227 | if (resp.status == 200) { 228 | logDebug("meross data: " + resp.data) 229 | respData = resp.data["data"] 230 | logDebug("meross data: " + respData) 231 | logDebug("meross data: " + respData[0]["uuid"]) 232 | } else { 233 | respData = [code: resp.status, error: "HTTP Protocol Error"] 234 | } 235 | } 236 | } catch (e) { 237 | def msg = "Error = ${e}\n\n" 238 | respData = [code: 9999, error: e] 239 | } 240 | 241 | return respData 242 | } 243 | 244 | def loginMeross(email, password) { 245 | def nonce = generator( (('A'..'Z')+('0'..'9')).join(), 16 ) 246 | def unix_time = (Integer)Math.floor(new Date().getTime() / 1000); 247 | 248 | def param = [:] 249 | param.email = email 250 | param.password = password 251 | def json = JsonOutput.toJson(param) 252 | def encoded_param = json.bytes.encodeBase64().toString(); 253 | 254 | def concat_sign = ["23x17ahWarFH6w29", unix_time, nonce, encoded_param].join("") 255 | MessageDigest digest = MessageDigest.getInstance('MD5') 256 | digest.update(concat_sign.bytes, 0, concat_sign.length()) 257 | def sign = new BigInteger(1, digest.digest()).toString(16) 258 | 259 | def formBody = "params=${encoded_param}&sign=${sign}×tamp=${unix_time}&nonce=${nonce}" 260 | 261 | def commandParams = [ 262 | uri: "https://iot.meross.com/v1/Auth/login", 263 | contentType: 'application/x-www-form-urlencoded', 264 | body : formBody 265 | ] 266 | def respData 267 | try { 268 | httpPost(commandParams) {resp -> 269 | if (resp.status == 200) { 270 | respData = resp.data.toString() 271 | def retobj = [:] 272 | retobj.code = 200 273 | retobj.token = "" 274 | retobj.key = "" 275 | logDebug("respData:" + respData) 276 | if(respData.indexOf('"token"')>0) 277 | { 278 | retobj.token = respData.substring(respData.indexOf('"token"')+9) 279 | retobj.token = retobj.token.substring(0,retobj.token.indexOf('"')) 280 | logDebug("token:" + retobj.token) 281 | } 282 | if(respData.indexOf('"key"')>0) 283 | { 284 | retobj.key = respData.substring(respData.indexOf('"key"')+7) 285 | retobj.key = retobj.key.substring(0,retobj.key.indexOf('"')) 286 | logDebug("key:" + retobj.key) 287 | } 288 | if(retobj.token.length()==0 || retobj.key.length()==0) 289 | { 290 | retobj.code = 9999 291 | retobj.error = "Invalid username/password" 292 | } 293 | respData = retobj 294 | } else { 295 | respData = [code: resp.status, error: "HTTP Protocol Error"] 296 | } 297 | } 298 | } catch (e) { 299 | def msg = "Error = ${e}\n\n" 300 | respData = [code: 9999, error: e] 301 | } 302 | 303 | return respData 304 | } 305 | 306 | def logDebug(msg) { 307 | if(debugLog) log.debug(msg) 308 | } 309 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /drivers/meross-smart-dimmer-plug.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Meross Smart Dimmer Plug 3 | * 4 | * Author: Todd Pike 5 | * Last updated: 2023-01-02 6 | * 7 | * Based on Smart Plug Mini by Daniel Tijerina with firmware update fix from coKaliszewski 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 10 | * use this file except in compliance with the License. You may obtain a copy 11 | * of the License at: 12 | * 13 | * http://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 17 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 18 | * License for the specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import java.security.MessageDigest 23 | 24 | metadata { 25 | definition( 26 | name: 'Meross Smart Dimmer Plug', 27 | namespace: 'bobted', 28 | author: 'Todd Pike' 29 | ) { 30 | capability 'Switch' 31 | capability 'SwitchLevel' 32 | capability 'Refresh' 33 | capability 'Configuration' 34 | 35 | command 'toggle' 36 | 37 | attribute 'level', 'number' 38 | attribute 'capacity', 'number' 39 | 40 | attribute 'model', 'string' 41 | attribute 'device uuid', 'string' 42 | attribute 'mac', 'string' 43 | attribute 'firmware', 'string' 44 | attribute 'userid', 'string' 45 | attribute 'modified', 'string' 46 | } 47 | preferences { 48 | section('Device Selection') { 49 | input('deviceIp', 'text', title: 'Device IP Address', description: '', required: true, defaultValue: '') 50 | input('key', 'text', title: 'Key', description: 'Encryption key for generating message payload', required: false, defaultValue: '') 51 | input('messageId', 'text', title: 'Message ID', description: '', required: true, defaultValue: '') 52 | input('uuid', 'text', title: 'Unique ID', description: '', required: true, defaultValue: '') 53 | input('timestamp', 'number', title: 'Timestamp', description: '', required: true, defaultValue: '') 54 | input('sign', 'text', title: 'Sign', description: '', required: true, defaultValue: '') 55 | input('dimmerLevel','number',title: 'Dimmer level', description:'', required: true, defaultValue: 75) 56 | input('DebugLogging', 'bool', title: 'Enable debug logging', defaultValue: true) 57 | } 58 | } 59 | } 60 | 61 | def getDriverVersion() { 62 | 1 63 | } 64 | 65 | def on() { 66 | log.info('Turning on') 67 | sendCommand(1, 0) 68 | } 69 | 70 | def off() { 71 | log.info('Turning off') 72 | sendCommand(0, 0) 73 | } 74 | 75 | def toggle() { 76 | log "toggling" 77 | 78 | if (device.currentValue("switch") == "on") { 79 | off() 80 | } 81 | else { 82 | on() 83 | } 84 | } 85 | 86 | def updated() { 87 | log.info('Updated') 88 | initialize() 89 | } 90 | 91 | def setLevel(level, duration) { 92 | setLevel(level) 93 | } 94 | 95 | def setLevel(level) { 96 | log.info("Setting level to ${level}") 97 | 98 | if (!settings.deviceIp || !settings.uuid || !settings.key) { 99 | sendEvent(name: 'switch', value: 'offline', isStateChange: false) 100 | log.warn('missing setting configuration') 101 | return 102 | } 103 | 104 | try { 105 | def payloadData = getPayload() 106 | 107 | def postBody = [ 108 | payload: [ 109 | light: [ 110 | channel: 0, 111 | luminance: level, 112 | capacity: 4 113 | ] 114 | ], 115 | header: [ 116 | messageId: "${payloadData.get('MessageId')}", 117 | method: "SET", 118 | from: "http://${settings.deviceIp}/config", 119 | timestamp: payloadData.get('CurrentTime'), 120 | namespace: "Appliance.Control.Light", 121 | uuid: "${settings.uuid}", 122 | sign: "${payloadData.get('Sign')}", 123 | triggerSrc: "hubitat", 124 | payloadVersion: 1 125 | ] 126 | ] 127 | 128 | def params = [ 129 | uri: "http://${settings.deviceIp}", 130 | path: "/config", 131 | contentType: "application/json", 132 | body: postBody, 133 | headers: [Connection: "keep-alive"] 134 | ] 135 | 136 | def callbackData = [:] 137 | callbackData.put(payloadData.get('MessageId'), level) 138 | asynchttpPost("setLevelRespons", params, callbackData) 139 | } catch (e) { 140 | log.error "setLevel hit an exception '${e}'" 141 | } 142 | } 143 | 144 | def setLevelRespons(resp, data) { 145 | def response = new groovy.json.JsonSlurper().parseText(resp.data) 146 | def level = data[response.header.messageId] 147 | 148 | if (resp.getStatus() != 200) { 149 | log.error "Received status code of '${resp.getStatus()}'. Could not set state to '${level}'." 150 | return 151 | } 152 | 153 | sendEvent(name: 'level', value: level, isStateChange: true) 154 | 155 | runInMillis(1000, 'refresh') 156 | } 157 | 158 | def sendCommand(int onoff, int channel) { 159 | if (!settings.deviceIp || !settings.uuid || !settings.key) { 160 | sendEvent(name: 'switch', value: 'offline', isStateChange: false) 161 | log.warn('missing setting configuration') 162 | return 163 | } 164 | 165 | try { 166 | def payloadData = getPayload() 167 | 168 | def postBody = [ 169 | payload: [ 170 | togglex: [ 171 | onoff: onoff, 172 | channel: channel 173 | ] 174 | ], 175 | header: [ 176 | messageId: "${payloadData.get('MessageId')}", 177 | method: "SET", 178 | from: "http://${settings.deviceIp}/config", 179 | timestamp: payloadData.get('CurrentTime'), 180 | namespace: "Appliance.Control.ToggleX", 181 | uuid: "${settings.uuid}", 182 | sign: "${payloadData.get('Sign')}", 183 | triggerSrc: "hubitat", 184 | payloadVersion: 1 185 | ] 186 | ] 187 | 188 | def params = [ 189 | uri: "http://${settings.deviceIp}", 190 | path: "/config", 191 | contentType: "application/json", 192 | body: postBody, 193 | headers: [Connection: "keep-alive"] 194 | ] 195 | 196 | def callbackData = [:] 197 | callbackData.put(payloadData.get('MessageId'), onoff) 198 | asynchttpPost("onoffResponse", params, callbackData) 199 | } catch (e) { 200 | log.error "sendCommand hit exception '${e}'" 201 | } 202 | } 203 | 204 | def onoffResponse(resp, data) { 205 | try { 206 | if (resp?.data?.trim()) { 207 | def response = new groovy.json.JsonSlurper().parseText(resp.data) 208 | def onoff = data[response.header.messageId] 209 | 210 | def state = onoff ? 'on' : 'off' 211 | if (resp.getStatus() != 200) { 212 | log.error "Received status code of '${resp.getStatus()}'. Could not set state to '${state}'." 213 | return 214 | } 215 | 216 | sendEvent(name: 'switch', value: state, isStateChange: true) 217 | } 218 | } 219 | catch (Exception e) { 220 | if (DebugLogging) { 221 | log.error "Error in response: '${e}'" 222 | } 223 | } 224 | 225 | runInMillis(500, 'refresh') 226 | } 227 | 228 | def refresh() { 229 | if (!settings.deviceIp || !settings.uuid || !settings.key) { 230 | sendEvent(name: 'switch', value: 'offline', isStateChange: false) 231 | log 'missing setting configuration' 232 | return 233 | } 234 | 235 | try { 236 | def payloadData = getPayload() 237 | 238 | def postBody = [ 239 | payload: [ ], 240 | header: [ 241 | messageId: "${payloadData.get('MessageId')}", 242 | method: "GET", 243 | from: "http://${settings.deviceIp}/config", 244 | timestamp: payloadData.get('CurrentTime'), 245 | namespace: "Appliance.System.All", 246 | sign: "${payloadData.get('Sign')}", 247 | triggerSrc: "hubitat", 248 | payloadVersion: 1 249 | ] 250 | ] 251 | 252 | def params = [ 253 | uri: "http://${settings.deviceIp}", 254 | path: "/config", 255 | contentType: "application/json", 256 | body: postBody, 257 | headers: [Connection: "keep-alive"] 258 | ] 259 | 260 | def callbackData = [:] 261 | callbackData.put("messageId", payloadData.get('MessageId')) 262 | asynchttpPost("refreshResponse", params, callbackData) 263 | } catch (Exception e) { 264 | log.error "refresh hit exception '${e}'" 265 | } 266 | } 267 | 268 | def refreshResponse(resp, data) { 269 | try { 270 | if (resp?.data?.trim()) { 271 | def response = new groovy.json.JsonSlurper().parseText(resp.data) 272 | def messageId = response.header.messageId 273 | def callbackId = data["messageId"] 274 | 275 | if (messageId != callbackId) { 276 | log.error "MessageId in refresh callback, '${callbackId}', does not match request, '${messageId}'. Skipping parse of refresh response." 277 | return 278 | } 279 | 280 | parse(resp.data) 281 | } 282 | } 283 | catch (Exception e) { 284 | if (DebugLogging) { 285 | log.error "Error in response: '${e}'" 286 | } 287 | } 288 | } 289 | 290 | def parse(String description) { 291 | def msg = new groovy.json.JsonSlurper().parseText(description) 292 | 293 | if (msg.header.method == "SETACK") return 294 | 295 | if (msg.payload.all) { 296 | def system = msg.payload.all.system 297 | def hardware = system.hardware 298 | sendEventOnChange('model', hardware.type) 299 | sendEventOnChange('device uuid', hardware.uuid) 300 | sendEventOnChange('mac', hardware.macAddress) 301 | 302 | def firmware = system.firmware 303 | sendEventOnChange('firmware', firmware.version) 304 | sendEventOnChange('userId', firmware.userId) 305 | 306 | def digest = msg.payload.all.digest 307 | def light = digest.light 308 | sendEventOnChange('level', light.luminance) 309 | sendEventOnChange('capacity', light.capacity) 310 | 311 | def status = digest.togglex[0] 312 | sendEventOnChange('modified', status.lmTime) 313 | sendEventOnChange('switch', status.onoff ? 'on' : 'off') 314 | } else { 315 | log.error ("Request failed") 316 | } 317 | } 318 | 319 | def sendEventOnChange(String name, value) { 320 | def current = getDataValue(name) 321 | if (current != value) { 322 | sendEvent(name: name, value: value, isStateChange: true) 323 | } 324 | 325 | updateDataValue(name, (value as String)) 326 | } 327 | 328 | def getPayload(int stringLength = 16) { 329 | 330 | // Generate a random string 331 | def chars = 'abcdefghijklmnopqrstuvwxyz0123456789' 332 | def randomString = new Random().with { (0..stringLength).collect { chars[ nextInt(chars.length() ) ] }.join()} 333 | 334 | int currentTime = new Date().getTime() / 1000 335 | messageId = MessageDigest.getInstance("MD5").digest((randomString + currentTime.toString()).bytes).encodeHex().toString() 336 | sign = MessageDigest.getInstance("MD5").digest((messageId + settings.key + currentTime.toString()).bytes).encodeHex().toString() 337 | 338 | def requestData = [ 339 | CurrentTime: currentTime, 340 | MessageId: messageId, 341 | Sign: sign 342 | ] 343 | 344 | return requestData 345 | } 346 | 347 | def log(msg) { 348 | if (DebugLogging) { 349 | log.debug(msg) 350 | } 351 | } 352 | 353 | def initialize() { 354 | log 'initialize()' 355 | refresh() 356 | 357 | log 'scheduling()' 358 | unschedule(refresh) 359 | runEvery1Minute(refresh) 360 | } 361 | 362 | def configure() { 363 | log 'configure()' 364 | initialize() 365 | } 366 | --------------------------------------------------------------------------------