Skip to content

Commit

Permalink
Sonoff MINI R2 (#83)
Browse files Browse the repository at this point in the history
* Add blueprint for Sonoff MINI R2

Co-authored-by: Arina Andreeva <[email protected]>
  • Loading branch information
Rina-an and Arina Andreeva authored Aug 25, 2022
1 parent 0770934 commit b2f397b
Show file tree
Hide file tree
Showing 3 changed files with 444 additions and 0 deletions.
32 changes: 32 additions & 0 deletions relays/sonoff_mini_r2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Sonoff MINI R2

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).

## Connect to Enapter

- 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)).
- Use [Enapter Gateway](https://go.enapter.com/handbook-gateway-setup) to run Virtual UCM.
- Create [Enapter Virtual UCM](https://go.enapter.com/handbook-vucm).
- [Upload](https://go.enapter.com/developers-upload-blueprint) this blueprint to Enapter Virtual UCM.
- Use the `Set Up Connection` command in the Enapter mobile or Web app to set up the Sonoff MINI R2 communication parameters:
- Device IP address;
- Port.

## How to find device IP Address and port information

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/).

- Get your device into [DYI Mode](https://sonoff.tech/diy-developer/).
- 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/).
- 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).
- 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`).
- In [Avahi](https://avahi.org/) the same information might look something like this:
- hostname = [eWeLink_<>.local];
- address = [192.168.42.100] - this is `IP address`;
- `port` = [8081].
- 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.

## References

- [Sonoff MINI R2 product page](https://sonoff.tech/product/diy-smart-switch/minir2/).
- [Sonoff HTTP API](https://sonoff.tech/sonoff-diy-developer-documentation-basicr3-rfr3-mini-http-api/).
303 changes: 303 additions & 0 deletions relays/sonoff_mini_r2/firmware.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
json = require("json")

-- Configuration variables must be also defined
-- in `write_configuration` command arguments in manifest.yml
IP_ADDRESS = 'ip_address'
PORT = 'port'

-- Initiate device firmware. Called at the end of the file.
function main()
scheduler.add(30000, send_properties)
scheduler.add(1000, send_telemetry)

enapter.register_command_handler('control_switch', control_switch)
config.init({
[IP_ADDRESS] = {type = 'string', required = true},
[PORT] = {type = 'string', required = true}
})
end

function send_properties()
local sonoff, err = connect_sonoff()
if err then
enapter.log("Can't connect to Sonoff: "..err)
return
else
local snf_data = sonoff:get_device_info()
if next(snf_data) then
enapter.send_properties({
vendor = 'Sonoff',
model = 'MINI R2',
fw_version = snf_data['data']['fwVersion'],
ip_address = sonoff.ip_address,
port = sonoff.port
})
end
end
end

function send_telemetry()
local sonoff, err = connect_sonoff()
if err then
enapter.log("Can't connect to Sonoff: "..err)
enapter.send_telemetry({
connection_status = 'error',
status = 'no_data',
alerts = {'connection_err'}
})
return
else
local snf_data = sonoff:get_device_info()
if snf_data ~= nil then
local telemetry = {}
telemetry.status = pretty_status(snf_data["data"]["switch"])
telemetry.signal = snf_data['data']['signalStrength']
telemetry.connection_status = 'ok'
telemetry.alerts = {}
enapter.send_telemetry(telemetry)
else
enapter.send_telemetry({
status = 'no_data',
connection_status = 'error',
alerts = {'no_data'}
})
end
end
end

function pretty_status(switch_state)
if switch_state == 'on' then
return 'switch_on'
elseif switch_state == 'off' then
return 'switch_off'
else
enapter.log("Unknown device state ", 'error')
return switch_state
end
end

-- holds global Sonoff connection
local sonoff

function connect_sonoff()
if sonoff and sonoff:get_device_info() then
return sonoff, nil
else
local values, err = config.read_all()
if err then
enapter.log('cannot read config: '..tostring(err), 'error')
return nil, 'cannot_read_config'
else
local ip_address, port = values[IP_ADDRESS], values[PORT]
if not ip_address or not port then
return nil, 'not_configured'
else
sonoff = Sonoff.new(ip_address, port)
return sonoff, nil
end
end
end
end

---------------------------------
-- Stored Configuration API
---------------------------------

config = {}

-- Initializes config options. Registers required UCM commands.
-- @param options: key-value pairs with option name and option params
-- @example
-- config.init({
-- address = { type = 'string', required = true },
-- unit_id = { type = 'number', default = 1 },
-- reconnect = { type = 'boolean', required = true }
-- })
function config.init(options)
assert(next(options) ~= nil, 'at least one config option should be provided')
assert(not config.initialized, 'config can be initialized only once')
for name, params in pairs(options) do
local type_ok = params.type == 'string' or params.type == 'number' or params.type == 'boolean'
assert(type_ok, 'type of `'..name..'` option should be either string or number or boolean')
end

enapter.register_command_handler('write_configuration', config.build_write_configuration_command(options))
enapter.register_command_handler('read_configuration', config.build_read_configuration_command(options))

config.options = options
config.initialized = true
end

-- Reads all initialized config options
-- @return table: key-value pairs
-- @return nil|error
function config.read_all()
local result = {}

for name, _ in pairs(config.options) do
local value, err = config.read(name)
if err then
return nil, 'cannot read `'..name..'`: '..err
else
result[name] = value
end
end

return result, nil
end

-- @param name string: option name to read
-- @return string
-- @return nil|error
function config.read(name)
local params = config.options[name]
assert(params, 'undeclared config option: `'..name..'`, declare with config.init')

local ok, value, ret = pcall(function()
return storage.read(name)
end)

if not ok then
return nil, 'error reading from storage: '..tostring(value)
elseif ret and ret ~= 0 then
return nil, 'error reading from storage: '..storage.err_to_str(ret)
elseif value then
return config.deserialize(name, value), nil
else
return params.default, nil
end
end

-- @param name string: option name to write
-- @param val string: value to write
-- @return nil|error
function config.write(name, val)
local ok, ret = pcall(function()
return storage.write(name, config.serialize(name, val))
end)

if not ok then
return 'error writing to storage: '..tostring(ret)
elseif ret and ret ~= 0 then
return 'error writing to storage: '..storage.err_to_str(ret)
end
end

-- Serializes value into string for storage
function config.serialize(_, value)
if value then
return tostring(value)
else
return nil
end
end

-- Deserializes value from stored string
function config.deserialize(name, value)
local params = config.options[name]
assert(params, 'undeclared config option: `'..name..'`, declare with config.init')

if params.type == 'number' then
return tonumber(value)
elseif params.type == 'string' then
return value
elseif params.type == 'boolean' then
if value == 'true' then
return true
elseif value == 'false' then
return false
else
return nil
end
end
end

function config.build_write_configuration_command(options)
return function(ctx, args)
for name, params in pairs(options) do
if params.required then
assert(args[name], '`'..name..'` argument required')
end

local err = config.write(name, args[name])
if err then ctx.error('cannot write `'..name..'`: '..err) end
end
end
end

function config.build_read_configuration_command(_config_options)
return function(ctx)
local result, err = config.read_all()
if err then
ctx.error(err)
else
return result
end
end
end

---------------------------------
-- Sonoff API
---------------------------------

Sonoff = {}

function Sonoff.new(ip_address, port)
assert(type(ip_address) == 'string', 'ip_address (arg #1) must be string, given: '..inspect(ip_address))
assert(type(port) == 'string', 'port (arg #2) must be string, given: '..inspect(port))

local self = setmetatable({}, { __index = Sonoff })
self.ip_address = ip_address
self.port = port
self.client = http.client({timeout = 10})
return self
end

function Sonoff:get_device_info()
local body = json.encode({
data = {},
deviceid =''
})

local response, err = self.client:post('http://'..self.ip_address..':'..self.port..'/zeroconf/info',
'application/json', body)

if err then
enapter.log('Cannot do request: '..err, 'error')
elseif response.code ~= 200 then
enapter.log('Request returned non-OK code: '..response.code, 'error')
else
return json.decode(response.body)
end
return nil
end

function control_switch(ctx, args)
if args['action'] then
local body = json.encode({
data = {switch = args['action']},
deviceid = ''
})
local connected_sonoff, sonoff_err = connect_sonoff()
if not sonoff_err then
local response, err = connected_sonoff.client:post(
'http://'..connected_sonoff.ip_address..':'..connected_sonoff.port..'/zeroconf/switch',
'json', body
)

if err then
ctx.error('Cannot do request: '..err, 'error')
elseif response.code ~= 200 then
ctx.error('Request returned non-OK code: '..response.code, 'error')
else
return json.decode(response.body)
end
else
ctx.error("Can't connect to Sonoff device: "..sonoff_err)
end
else
ctx.error('No action argument')
end
end

main()
Loading

0 comments on commit b2f397b

Please sign in to comment.