Skip to content

Commit b2f397b

Browse files
Rina-anArina Andreeva
andauthored
Sonoff MINI R2 (#83)
* Add blueprint for Sonoff MINI R2 Co-authored-by: Arina Andreeva <[email protected]>
1 parent 0770934 commit b2f397b

File tree

3 files changed

+444
-0
lines changed

3 files changed

+444
-0
lines changed

β€Žrelays/sonoff_mini_r2/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Sonoff MINI R2
2+
3+
This [Enapter Device Blueprint](https://go.enapter.com/marketplace-readme) integrates **Sonoff MINI R2** - a Wi-Fi DIY smart switch - via [HTTP API](https://go.enapter.com/developers-enapter-http) implemented on [Enapter Virtual UCM](https://go.enapter.com/handbook-vucm).
4+
5+
## Connect to Enapter
6+
7+
- Sign up to Enapter Cloud using [Web](https://cloud.enapter.com/) or mobile app ([iOS](https://apps.apple.com/app/id1388329910), [Android](https://play.google.com/store/apps/details?id=com.enapter&hl=en)).
8+
- Use [Enapter Gateway](https://go.enapter.com/handbook-gateway-setup) to run Virtual UCM.
9+
- Create [Enapter Virtual UCM](https://go.enapter.com/handbook-vucm).
10+
- [Upload](https://go.enapter.com/developers-upload-blueprint) this blueprint to Enapter Virtual UCM.
11+
- Use the `Set Up Connection` command in the Enapter mobile or Web app to set up the Sonoff MINI R2 communication parameters:
12+
- Device IP address;
13+
- Port.
14+
15+
## How to find device IP Address and port information
16+
17+
There are a great many mDNS tools to choose from, so use whichever works best for you. For example purposes, we will cover [Discovery App for macOS](https://apps.apple.com/us/app/discovery-dns-sd-browser/id1381004916?mt=12) and [Avahi](https://avahi.org/).
18+
19+
- Get your device into [DYI Mode](https://sonoff.tech/diy-developer/).
20+
- After your device was connected to your Wi-Fi, you can start scanning local network with [Discovery](https://apps.apple.com/us/app/discovery-dns-sd-browser/id1381004916?mt=12) or [Avahi](https://avahi.org/).
21+
- In local networks Sonoff MINI R2 can usually be detected as _ewelink._tcp using [Ahavi](https://avahi.org/) and _ewelink._tcp (eWeLink devices supporting LAN control) using [Discovery](https://apps.apple.com/us/app/discovery-dns-sd-browser/id1381004916?mt=12).
22+
- In [Discovery app](https://apps.apple.com/us/app/discovery-dns-sd-browser/id1381004916?mt=12) click on the drop-down list next to _ewelink._tcp and look for IP address and port information (e.g. 192.168.42.100:8081, 192.168.42.100 being `IP address` and 8081 being `port`).
23+
- In [Avahi](https://avahi.org/) the same information might look something like this:
24+
- hostname = [eWeLink_<>.local];
25+
- address = [192.168.42.100] - this is `IP address`;
26+
- `port` = [8081].
27+
- Write down `IP address` and `port` of your device and use this information in the `Set Up Connection` command in the Enapter mobile or Web app to set up the Sonoff MINI R2 communication parameters.
28+
29+
## References
30+
31+
- [Sonoff MINI R2 product page](https://sonoff.tech/product/diy-smart-switch/minir2/).
32+
- [Sonoff HTTP API](https://sonoff.tech/sonoff-diy-developer-documentation-basicr3-rfr3-mini-http-api/).

β€Žrelays/sonoff_mini_r2/firmware.lua

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
json = require("json")
2+
3+
-- Configuration variables must be also defined
4+
-- in `write_configuration` command arguments in manifest.yml
5+
IP_ADDRESS = 'ip_address'
6+
PORT = 'port'
7+
8+
-- Initiate device firmware. Called at the end of the file.
9+
function main()
10+
scheduler.add(30000, send_properties)
11+
scheduler.add(1000, send_telemetry)
12+
13+
enapter.register_command_handler('control_switch', control_switch)
14+
config.init({
15+
[IP_ADDRESS] = {type = 'string', required = true},
16+
[PORT] = {type = 'string', required = true}
17+
})
18+
end
19+
20+
function send_properties()
21+
local sonoff, err = connect_sonoff()
22+
if err then
23+
enapter.log("Can't connect to Sonoff: "..err)
24+
return
25+
else
26+
local snf_data = sonoff:get_device_info()
27+
if next(snf_data) then
28+
enapter.send_properties({
29+
vendor = 'Sonoff',
30+
model = 'MINI R2',
31+
fw_version = snf_data['data']['fwVersion'],
32+
ip_address = sonoff.ip_address,
33+
port = sonoff.port
34+
})
35+
end
36+
end
37+
end
38+
39+
function send_telemetry()
40+
local sonoff, err = connect_sonoff()
41+
if err then
42+
enapter.log("Can't connect to Sonoff: "..err)
43+
enapter.send_telemetry({
44+
connection_status = 'error',
45+
status = 'no_data',
46+
alerts = {'connection_err'}
47+
})
48+
return
49+
else
50+
local snf_data = sonoff:get_device_info()
51+
if snf_data ~= nil then
52+
local telemetry = {}
53+
telemetry.status = pretty_status(snf_data["data"]["switch"])
54+
telemetry.signal = snf_data['data']['signalStrength']
55+
telemetry.connection_status = 'ok'
56+
telemetry.alerts = {}
57+
enapter.send_telemetry(telemetry)
58+
else
59+
enapter.send_telemetry({
60+
status = 'no_data',
61+
connection_status = 'error',
62+
alerts = {'no_data'}
63+
})
64+
end
65+
end
66+
end
67+
68+
function pretty_status(switch_state)
69+
if switch_state == 'on' then
70+
return 'switch_on'
71+
elseif switch_state == 'off' then
72+
return 'switch_off'
73+
else
74+
enapter.log("Unknown device state ", 'error')
75+
return switch_state
76+
end
77+
end
78+
79+
-- holds global Sonoff connection
80+
local sonoff
81+
82+
function connect_sonoff()
83+
if sonoff and sonoff:get_device_info() then
84+
return sonoff, nil
85+
else
86+
local values, err = config.read_all()
87+
if err then
88+
enapter.log('cannot read config: '..tostring(err), 'error')
89+
return nil, 'cannot_read_config'
90+
else
91+
local ip_address, port = values[IP_ADDRESS], values[PORT]
92+
if not ip_address or not port then
93+
return nil, 'not_configured'
94+
else
95+
sonoff = Sonoff.new(ip_address, port)
96+
return sonoff, nil
97+
end
98+
end
99+
end
100+
end
101+
102+
---------------------------------
103+
-- Stored Configuration API
104+
---------------------------------
105+
106+
config = {}
107+
108+
-- Initializes config options. Registers required UCM commands.
109+
-- @param options: key-value pairs with option name and option params
110+
-- @example
111+
-- config.init({
112+
-- address = { type = 'string', required = true },
113+
-- unit_id = { type = 'number', default = 1 },
114+
-- reconnect = { type = 'boolean', required = true }
115+
-- })
116+
function config.init(options)
117+
assert(next(options) ~= nil, 'at least one config option should be provided')
118+
assert(not config.initialized, 'config can be initialized only once')
119+
for name, params in pairs(options) do
120+
local type_ok = params.type == 'string' or params.type == 'number' or params.type == 'boolean'
121+
assert(type_ok, 'type of `'..name..'` option should be either string or number or boolean')
122+
end
123+
124+
enapter.register_command_handler('write_configuration', config.build_write_configuration_command(options))
125+
enapter.register_command_handler('read_configuration', config.build_read_configuration_command(options))
126+
127+
config.options = options
128+
config.initialized = true
129+
end
130+
131+
-- Reads all initialized config options
132+
-- @return table: key-value pairs
133+
-- @return nil|error
134+
function config.read_all()
135+
local result = {}
136+
137+
for name, _ in pairs(config.options) do
138+
local value, err = config.read(name)
139+
if err then
140+
return nil, 'cannot read `'..name..'`: '..err
141+
else
142+
result[name] = value
143+
end
144+
end
145+
146+
return result, nil
147+
end
148+
149+
-- @param name string: option name to read
150+
-- @return string
151+
-- @return nil|error
152+
function config.read(name)
153+
local params = config.options[name]
154+
assert(params, 'undeclared config option: `'..name..'`, declare with config.init')
155+
156+
local ok, value, ret = pcall(function()
157+
return storage.read(name)
158+
end)
159+
160+
if not ok then
161+
return nil, 'error reading from storage: '..tostring(value)
162+
elseif ret and ret ~= 0 then
163+
return nil, 'error reading from storage: '..storage.err_to_str(ret)
164+
elseif value then
165+
return config.deserialize(name, value), nil
166+
else
167+
return params.default, nil
168+
end
169+
end
170+
171+
-- @param name string: option name to write
172+
-- @param val string: value to write
173+
-- @return nil|error
174+
function config.write(name, val)
175+
local ok, ret = pcall(function()
176+
return storage.write(name, config.serialize(name, val))
177+
end)
178+
179+
if not ok then
180+
return 'error writing to storage: '..tostring(ret)
181+
elseif ret and ret ~= 0 then
182+
return 'error writing to storage: '..storage.err_to_str(ret)
183+
end
184+
end
185+
186+
-- Serializes value into string for storage
187+
function config.serialize(_, value)
188+
if value then
189+
return tostring(value)
190+
else
191+
return nil
192+
end
193+
end
194+
195+
-- Deserializes value from stored string
196+
function config.deserialize(name, value)
197+
local params = config.options[name]
198+
assert(params, 'undeclared config option: `'..name..'`, declare with config.init')
199+
200+
if params.type == 'number' then
201+
return tonumber(value)
202+
elseif params.type == 'string' then
203+
return value
204+
elseif params.type == 'boolean' then
205+
if value == 'true' then
206+
return true
207+
elseif value == 'false' then
208+
return false
209+
else
210+
return nil
211+
end
212+
end
213+
end
214+
215+
function config.build_write_configuration_command(options)
216+
return function(ctx, args)
217+
for name, params in pairs(options) do
218+
if params.required then
219+
assert(args[name], '`'..name..'` argument required')
220+
end
221+
222+
local err = config.write(name, args[name])
223+
if err then ctx.error('cannot write `'..name..'`: '..err) end
224+
end
225+
end
226+
end
227+
228+
function config.build_read_configuration_command(_config_options)
229+
return function(ctx)
230+
local result, err = config.read_all()
231+
if err then
232+
ctx.error(err)
233+
else
234+
return result
235+
end
236+
end
237+
end
238+
239+
---------------------------------
240+
-- Sonoff API
241+
---------------------------------
242+
243+
Sonoff = {}
244+
245+
function Sonoff.new(ip_address, port)
246+
assert(type(ip_address) == 'string', 'ip_address (arg #1) must be string, given: '..inspect(ip_address))
247+
assert(type(port) == 'string', 'port (arg #2) must be string, given: '..inspect(port))
248+
249+
local self = setmetatable({}, { __index = Sonoff })
250+
self.ip_address = ip_address
251+
self.port = port
252+
self.client = http.client({timeout = 10})
253+
return self
254+
end
255+
256+
function Sonoff:get_device_info()
257+
local body = json.encode({
258+
data = {},
259+
deviceid =''
260+
})
261+
262+
local response, err = self.client:post('http://'..self.ip_address..':'..self.port..'/zeroconf/info',
263+
'application/json', body)
264+
265+
if err then
266+
enapter.log('Cannot do request: '..err, 'error')
267+
elseif response.code ~= 200 then
268+
enapter.log('Request returned non-OK code: '..response.code, 'error')
269+
else
270+
return json.decode(response.body)
271+
end
272+
return nil
273+
end
274+
275+
function control_switch(ctx, args)
276+
if args['action'] then
277+
local body = json.encode({
278+
data = {switch = args['action']},
279+
deviceid = ''
280+
})
281+
local connected_sonoff, sonoff_err = connect_sonoff()
282+
if not sonoff_err then
283+
local response, err = connected_sonoff.client:post(
284+
'http://'..connected_sonoff.ip_address..':'..connected_sonoff.port..'/zeroconf/switch',
285+
'json', body
286+
)
287+
288+
if err then
289+
ctx.error('Cannot do request: '..err, 'error')
290+
elseif response.code ~= 200 then
291+
ctx.error('Request returned non-OK code: '..response.code, 'error')
292+
else
293+
return json.decode(response.body)
294+
end
295+
else
296+
ctx.error("Can't connect to Sonoff device: "..sonoff_err)
297+
end
298+
else
299+
ctx.error('No action argument')
300+
end
301+
end
302+
303+
main()

0 commit comments

Comments
Β (0)