diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..94dab79ee1b99c39c280bd0c0c2dbf39bf009851 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Smart Building Lab 1 / Part 2: Z-Wave # + +This is a template of a Python flask-based application for controlling a +Z-Wave IoT deployment. + +The `backend.py` file is to be completed in the parts marked by: + +``` +#### COMPLETE THIS METHOD #### +``` + +Once done, you can start your server with the command +``` +python2 flask-main.py +``` +then, wait ~1 minute for the network to stabilize, and check the connection with a browser, +normally at the URL: +``` +http://192.168.1.2:5000 +``` + +## Raspberry set-up ## + +In order to share a network card as an internet gateway in Linux, ip +[forwarding and masquerading must be setup](https://wiki.archlinux.org/index.php/Internet_sharing). The +`util/internet-sharing` script does the trick: + +``` +#!/bin/bash +################################################################################ +# internet-sharing +# +# Forward outbound traffic from a client interface to an internet gateway +# interface. +# +# <https://wiki.archlinux.org/index.php/Internet_sharing> +# <https://linoxide.com/firewall/ip-forwarding-connecting-private-interface-internet/> +################################################################################ +gwint=${1:-'net0'} # internet gateway interface -- all outbound traffic +clint=${2:-'net1'} # client interface -- input traffic + +sysctl net.ipv4.ip_forward=1 net.ipv6.conf.default.forwarding=1 net.ipv6.conf.all.forwarding=1 + +iptables -t nat -A POSTROUTING -o $gwint -j MASQUERADE +iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +iptables -A FORWARD -i $clint -o $gwint -j ACCEPT +``` + +By default, it expects two interfaces named `net0` and `net1` for, +respectively, the internet gateway and the client inbound interface. So, if +you want to reroute all incoming traffic from, say, `eth0` (where your +Raspberry is connected to) through your wireless connection `wlan0`, you'd call +the script like this (as **superuser**): + +``` +# ./internet-sharing wlan0 eth0 +``` + +To make the changes persistent, consult your Linux distribution's documentation. + +## Testing ## + +A CL-based client script is available: `post_client.py`. See how to use it with +``` +python3 post_client.py -h +``` diff --git a/post_client.py b/post_client.py index 99c37174bd43ebf17b89e562ca4e936bfd19ac4f..2717eac4b8797ab81108a46d34fd714b2ce1637e 100644 --- a/post_client.py +++ b/post_client.py @@ -1,44 +1,447 @@ -import requests -import json +#!/usr/bin/env python3 +################################################################################ +"""post_client.py -- demo client script for hepia's LSDS IoT Z-Wave Lab -############################################################# -#### This script sends POST or PUT http request to server ### -############################################################# -#### You have to uncomment the request you want to send ### -############################################################# -#### POST requests contain parameters in JSON format ### -############################################################# +This script sends POST or GET HTTP requests to a REST server. POST requests +contain parameters in JSON format. +Usage: - -#### Configuration of nodes -req = requests.post('http://192.168.1.2:5000/network/set_sensor_nodes_basic_configuration',headers={'Content-Type': 'application/json'}, data=json.dumps({'Group_Interval': '240','Group_Reports':'240', 'Wake-up_Interval': '480'})) + post_client.py [OPTIONS] CLASS COMMAND +See `post_client.py -h` for the details -#### Config a specific parameter -#req = requests.post('http://192.168.1.2:5000/nodes/set_parameter',headers={'Content-Type': 'application/json'}, data=json.dumps({'node_id': '4','value':'480', 'parameter_index': '111', 'size': '4'})) +REST command paths +================== -#### Set node location -#req = requests.post('http://192.168.1.2:5000/nodes/set_location',headers={'Content-Type': 'application/json'}, data=json.dumps({'node_id': '4','value':'A402'})) +GET requests +------------ +URL path: -#### Set node name -#req = requests.post('http://192.168.1.2:5000/nodes/set_name',headers={'Content-Type': 'application/json'}, data=json.dumps({'node_id': '4','value':'sensor'})) + /<class>[/<node_id>]/<command>[/<parameter_index>] +where `node_id` and `parameter_index` must be specified via explicit CL +options, respectively `-n` and `-i`. -#### Send command to switch -#req = requests.post('http://192.168.1.2:5000/switches/send_command',headers={'Content-Type': 'application/json'}, data=json.dumps({'node_id': '3','value':'on'})) -#### Send command to dimmer -#req = requests.post('http://192.168.1.2:5000/dimmers/set_level',headers={'Content-Type': 'application/json'}, data=json.dumps({'node_id': '6','value':'120'})) +POST requests +------------- -#### Put controller in inclusion mode -#req = requests.post('http://192.168.1.2:5000/nodes/add_node') +URL path: + /<class>/<command> -#### Put controller in exclusion mode -#req = requests.post('http://192.168.1.2:5000/nodes/remove_node') +with JSON payload specified via the CL option `-d`. -print (req.text) # print server response +Examples +======== + +Dump network topology: + + post_client.py network info + + +Switch to inclusion mode (20s timeout): + + post_client.py nodes add_node + + +Switch to exclusion mode (20s timeout): + + post_client.py nodes remove_node + + +Get dimmer level for a specific node (given as CL option `-n`): + + post_client.py dimmers get_level -n 5 + + +Set dimmer level for a specific node (notice how the ID is specified in +the JSON payload): + + post_client.py dimmers set_level -d '{"node_id": 5, "value": 50}' + + +Get value for a generic parameter at index '111' for node '5': + + python3 post_client.py nodes get_parameter -n 5 -i 111 + + +Set value for a generic parameter at index '111' for node '5': + + python3 post_client.py nodes set_parameter \ + -d '{"node_id": 5, "value": 480, "parameter_index": 111, "size": 4}' + + +Get all measures for a given node + + post_client.py sensors get_all_measures -n 2 + + +Bugs +==== + +* At least CLASS and COMMAND must be given to get the script's manual with + `-m`. + +* Setting generic parameter doesn't seem to work properly: (the parameter is + created if not existing,) it's value stays always at 0. +""" +################################################################################ +import requests, argparse, json, sys, copy, logging + +logging.basicConfig( + level=logging.INFO, + format='%(levelname)-8s %(message)s' +) +logger = logging.getLogger('rest_client') + + +################################################################################ +# constants + +http_headers = { + 'Content-Type' : 'application/json' +} + +# cross reference with default node ID, par index and data (where needed) +command_xref = { + 'network' : { + 'get_nodes_configuration' : { + 'method' : 'GET', + }, + 'info' : { + 'method' : 'GET', + }, + 'reset' : { # possibly broken + 'method' : 'GET', + }, + 'set_sensor_nodes_basic_configuration' : { + 'method' : 'POST', + 'data' : { + 'Group_Interval': '240', + 'Group_Reports': '240', + 'Wake-up_Interval': '480' + } + }, + 'start' : { + 'method' : 'GET', + }, + 'stop' : { + 'method' : 'GET', + }, + }, + + 'nodes' : { + 'add_node' : { + 'method' : 'POST', + }, + 'get_battery' : { + 'method' : 'GET', + 'node_id' : 1, + }, + 'get_location' : { + 'method' : 'GET', + 'node_id' : 1, + }, + 'get_name' : { + 'method' : 'GET', + 'node_id' : 1, + }, + 'get_neighbours' : { + 'method' : 'GET', + 'node_id' : 1, + }, + 'get_nodes_list' : { + 'method' : 'GET', + }, + 'get_parameter' : { + 'method' : 'GET', + 'node_id' : 1, + 'parameter_index' : 111, + }, + 'remove_node' : { + 'method' : 'POST', + }, + 'set_location' : { + 'method' : 'POST', + 'data' : { + 'node_id' : '1', + 'value' : 'A402' + } + }, + 'set_name' : { + 'method' : 'POST', + 'data' : { + 'node_id' : '2', + 'value' : 'sensor' + } + }, + 'set_parameter' : { + 'method' : 'POST', + 'data' : { + 'node_id' : '3', + 'value' : '480', + 'parameter_index' : '111', + 'size' : '4' + } + } + }, + 'dimmers' : { + 'get_dimmers_list' : { + 'method' : 'GET', + }, + 'get_level' : { + 'method' : 'GET', + 'node_id' : 1, + }, + 'set_level' : { + 'method' : 'POST', + 'data' : { + 'node_id': '5', + 'value': '10' + } + } + }, + 'sensors' : { + 'get_all_measures' : { + 'method' : 'GET', + 'node_id' : 2, + }, + 'get_humidity' : { + 'method' : 'GET', + 'node_id' : 2, + }, + 'get_luminance' : { + 'method' : 'GET', + 'node_id' : 2, + }, + 'get_motion' : { + 'method' : 'GET', + 'node_id' : 2, + }, + 'get_sensors_list' : { + 'method' : 'GET', + }, + 'get_temperature' : { + 'method' : 'GET', + 'node_id' : 2, + }, + }, +} + + +################################################################################ +# functions + +def build_request_path( + cclass, command, cmnd_s, + node_id=None, + parameter_index=None, +): + """Build a request path. + + :returns str: a string like '/<class>[/<node_id>]/<command>[/<parameter>]' + or None on errors + + Arguments + +++++++++ + + :param str cclass: the resource/object class + + :param str command: the command name + + :param dict cmnd_s: the command spec dict as in `command_xref` + + Keywords arguments + ++++++++++++++++++ + + :param int node_id: the target node to query + + :param int parameter_index: the parameter index to query + + """ + if 'node_id' in cmnd_s.keys(): + node_id = node_id or cmnd_s['node_id'] + elif node_id: + logger.error( + "/{}/{}: unexepected 'node_id' ({}) for this request".format( + cclass, command, node_id + ) + ) + return None + + if 'parameter_index' in cmnd_s.keys(): + parameter_index = parameter_index or cmnd_s['parameter_index'] + elif parameter_index: + logger.error( + "/{}/{}: unexepected 'parameter_index' ({}) for this request".format( + cclass, command, parameter_index + ) + ) + return None + + return '/' + cclass + '/' \ + + (( str(node_id) + '/' ) if node_id != None else '') + command \ + + (( '/' + str(parameter_index) ) if parameter_index != None else '') + + +################################################################################ +# main + +parser = argparse.ArgumentParser( + description='Demo client for REST-based Z-Wave deployments -- hepia/LSDS Smart-Building', + formatter_class=argparse.ArgumentDefaultsHelpFormatter +) + +parser.add_argument( + '-m', '--manual', + dest='manual', + action='store_true', + help='print the full documentation' +) + +parser.add_argument( + '-u', '--server-url', + dest='server_url', + type=str, + default='http://192.168.1.2', + help='REST server URL' +) + +parser.add_argument( + '-p', '--server-port', + dest='server_port', + type=int, + default=5000, + help='REST server PORT' +) + +parser.add_argument( + '-n', '--node-id', + dest='node_id', + type=int, + help='Node ID' +) + +parser.add_argument( + '-d', '--data', + dest='data', + type=str, + help='JSON payload string for POST requests' +) + +parser.add_argument( + '-i', '--parameter-index', + dest='parameter_index', + type=int, + help='Parameter index' +) + +parser.add_argument( + 'class', + # no metavar, else choices won't be proposed on -h + type=str, + choices=('network', 'nodes', 'dimmers', 'sensors'), + help='Device/object class' +) + +parser.add_argument( + 'command', + type=str, + choices=( + # network + 'get_nodes_configuration', + 'info', + 'reset', + 'set_sensor_nodes_basic_configuration', + 'start', + 'stop', + + # nodes + 'add_node', + 'get_battery', + 'get_location', + 'get_name', + 'get_neighbours', + 'get_nodes_list', + 'get_parameter', + 'remove_node', + 'set_location', + 'set_name', + 'set_parameter', + + # dimmers + 'get_dimmers_list', + 'get_level', + 'set_level', + + # sensors + 'get_all_measures', + 'get_humidity', + 'get_luminance', + 'get_motion', + 'get_sensors_list', + 'get_temperature' + ), + help='Command to send' +) + + # easier with a dict +args = vars(parser.parse_args()) + +if args['manual']: + print(__doc__) + sys.exit(0) + +cclass = args['class'] +command = args['command'] +data = {} +if args['data']: + try: + data = json.loads(args['data']) + except Exception as e: + logger.error("{}: invalid JSON data: {}".format(args['data'], e)) + sys.exit(1) + +cmnd_s = {} +try: + cmnd_s = command_xref[cclass][command] +except KeyError: + parser.print_help() + logger.error("{}: {}: no such command available for this class".format(cclass, command)) + sys.exit(1) + +base_url = "{}:{}".format(args['server_url'], args['server_port']) + +command_path = build_request_path( + cclass, command, cmnd_s, + node_id=args['node_id'], + parameter_index=args['parameter_index'] +) + +if not command_path: + logger.fatal("Can't build command path") + sys.exit(1) + +method = cmnd_s['method'] +if 'data' in cmnd_s.keys(): + data = data or cmnd_s['data'] +elif data: + sys.exit("{}: unexpected data for command path".format(command_path)) + +logger.info("Sending {} request path: '{}'".format(method, command_path)) +if data: + logger.info("With payload: '{}'".format(data)) + +req = requests.request( + method, base_url + command_path, + headers=http_headers, + data=json.dumps(data) +) + +print("Server response:\n[{}] {}".format(req.status_code, req.text)) +sys.exit(0) diff --git a/util/internet-sharing b/util/internet-sharing new file mode 100755 index 0000000000000000000000000000000000000000..7905cbf7dbae6f2d409c329a54214cda89e21202 --- /dev/null +++ b/util/internet-sharing @@ -0,0 +1,18 @@ +#!/bin/bash +################################################################################ +# internet-sharing +# +# Forward outbound traffic from a client interface to an internet gateway +# interface. +# +# <https://wiki.archlinux.org/index.php/Internet_sharing> +# <https://linoxide.com/firewall/ip-forwarding-connecting-private-interface-internet/> +################################################################################ +gwint=${1:-'net0'} # internet gateway interface -- all outbound traffic +clint=${2:-'net1'} # client interface -- input traffic + +sysctl net.ipv4.ip_forward=1 net.ipv6.conf.default.forwarding=1 net.ipv6.conf.all.forwarding=1 + +iptables -t nat -A POSTROUTING -o $gwint -j MASQUERADE +iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +iptables -A FORWARD -i $clint -o $gwint -j ACCEPT