diff --git a/relays/sonoff_mini_r2/README.md b/relays/sonoff_mini_r2/README.md new file mode 100644 index 00000000..c5bfbb68 --- /dev/null +++ b/relays/sonoff_mini_r2/README.md @@ -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/). diff --git a/relays/sonoff_mini_r2/firmware.lua b/relays/sonoff_mini_r2/firmware.lua new file mode 100644 index 00000000..8319ce4e --- /dev/null +++ b/relays/sonoff_mini_r2/firmware.lua @@ -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() diff --git a/relays/sonoff_mini_r2/manifest.yml b/relays/sonoff_mini_r2/manifest.yml new file mode 100644 index 00000000..4599bb24 --- /dev/null +++ b/relays/sonoff_mini_r2/manifest.yml @@ -0,0 +1,109 @@ +blueprint_spec: device/1.0 + +display_name: Sonoff MINI R2 +description: Smart Relay + +communication_module: + product: ENP-VIRTUAL + lua_file: firmware.lua + +properties: + vendor: + type: string + display_name: Vendor + model: + type: string + display_name: Model + fw_version: + type: string + display_name: Sonoff Firmware Version + ip_address: + type: string + display_name: Sonoff IP address + port: + type: string + display_name: Sonoff Port + +telemetry: + status: + type: string + display_name: Switch state + enum: + - switch_on + - switch_off + - no_data + connection_status: + type: string + display_name: Connection Status + enum: + - ok + - error + signal: + type: integer + display_name: Wi-Fi Signal Strength + unit: dBm + fwversion: + type: string + display_name: Sonoff Firmware Version + +alerts: + connection_err: + severity: error + display_name: Connection Error + description: > + Please use 'Set Up Connection' command to set up your + Sonoff device configuration. + no_data: + severity: error + display_name: No data from device + description: > + Can't read data from Sonoff MINI R2. + Please check configuration parameters. + +command_groups: + connection: + display_name: Connection + +commands: + write_configuration: + display_name: Set Up Connection + description: Set connection parameters of your Sonoff Mini R2 + group: connection + populate_values_command: read_configuration + ui: + icon: file-document-edit-outline + arguments: + ip_address: + display_name: Sonoff IP address + type: string + required: true + port: + display_name: Sonoff port + type: string + required: true + read_configuration: + display_name: Read Connection Parameters + description: Read your Sonoff IP Address and port information + group: connection + ui: + icon: file-check-outline + control_switch: + display_name: Control Switch + description: Turns switch on and off + group: connection + ui: + icon: play-outline + arguments: + action: + display_name: Action + required: true + type: string + enum: + - 'on' + - 'off' + +.cloud: + mobile_main_chart: signal + mobile_charts: + - signal + - switch