├── .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 |
--------------------------------------------------------------------------------