-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Add blueprint for Sonoff MINI R2 Co-authored-by: Arina Andreeva <[email protected]>
- Loading branch information
Showing
3 changed files
with
444 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Oops, something went wrong.