From 5d28cd7f190f90ade9c1fb740f0ccb14155be252 Mon Sep 17 00:00:00 2001
From: Marco Emilio Poleggi <marco-emilio.poleggi@hesge.ch>
Date: Tue, 15 Sep 2020 18:10:13 +0200
Subject: [PATCH] Doc revision. Added makefile, ecnrypted solution, tests, etc

---
 .gitattributes                  |   2 +
 .gitmodules                     |   2 +
 Makefile                        |  77 +++++
 README.md                       | 131 ++++++-
 examples/knx_client_script.py   |   1 -
 knx_client_script.py            |   3 +-
 knx_client_script.py.complete   | Bin 0 -> 19938 bytes
 knx_client_script.py.incomplete | 596 ++++++++++++++++++++++++++++++++
 tests/README.md                 |  58 ++++
 9 files changed, 849 insertions(+), 21 deletions(-)
 create mode 100644 .gitattributes
 create mode 100644 Makefile
 delete mode 100644 examples/knx_client_script.py
 mode change 100644 => 120000 knx_client_script.py
 create mode 100755 knx_client_script.py.complete
 create mode 100755 knx_client_script.py.incomplete
 create mode 100644 tests/README.md

diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..07f9fb1
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+*.py.* gitlab-language=python
+knx_client_script.py.complete filter=git-crypt diff=git-crypt
diff --git a/.gitmodules b/.gitmodules
index 185a74b..bb72878 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,6 +1,8 @@
 [submodule "knxnet"]
 	path = knxnet
 	url = ssh://git@ssh.hesge.ch:10572/adrienma.lescourt/knxnet_iot.git
+    update = merge
 [submodule "actuasim"]
 	path = actuasim
 	url = ssh://git@ssh.hesge.ch:10572/adrienma.lescourt/actuasim_iot.git
+    update = merge
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b98bd31
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,77 @@
+.ONESHELL:
+
+sources := knx_client_script.py
+py_artifacts := __pycache__/
+git_artifacts := .git*
+lab_artifacts := labo-setup/
+py_files := $(wildcard *.py)
+
+.PHONY: help
+
+all: help
+
+pycheck: $(py_files)
+	python -m py_compile $?
+
+# ensure symlinks are local to the source directory
+_slink:
+	dname=$(dir $(slink))
+	lname=$(notdir $(slink))
+	pushd $$dname >/dev/null
+	ln -sf $$lname.$(suffix) $$lname
+	popd >/dev/null
+
+incomplete: $(sources)
+	$(MAKE) suffix=incomplete _slink slink=$<
+labo: incomplete
+
+unlock:
+	git-crypt unlock
+
+complete: $(sources)
+	$(MAKE) suffix=complete _slink slink=$<
+
+solution: complete
+
+RUSER := marco
+RHOST := pcnabil
+RPATH := /home/marco/IoT/KNX
+deploy: complete pycheck
+	# use a subshell to correctly see the last newline. Rsync exclude is
+	# handeled via STDIN
+	(cat | rsync -CaLhv --exclude-from=- ./ $(RUSER)@$(RHOST):$(RPATH)) <<EOT
+	$(addsuffix .complete,$(sources))
+	$(addsuffix .incomplete,$(sources))
+	$(py_artifacts)
+	$(git_artifacts)
+	$(lab_artifacts)
+	EOT
+
+
+clean:
+	rm -rf __pycache__/
+
+reset: clean incomplete
+	# No-op
+
+define _help_msg :=
+Usage:
+
+  make [target]
+
+Targets:
+
+  clean         remove build artifacts and specious files & dirs
+  complete      prepare the source code for the 'solution' build
+  deploy        deploy the (complete) app to "$(RHOST):$(RPATH)"
+  help          guess what ;-)
+  incomplete    prepare the source code for the 'labo' build
+  labo          alias => 'incomplete'
+  pycheck       syntay check of Python modules
+  solution      alias => 'complete'
+  unlock        decrypt sensitive files
+endef
+
+# the shell no-op silences 'nothing to be done...' messages
+help:
+	@: $(info $(_help_msg))
diff --git a/README.md b/README.md
index 0b271c8..6e0ddff 100644
--- a/README.md
+++ b/README.md
@@ -7,20 +7,32 @@ The first part of the course is based on KNX, [an open standard for commercial a
 
 ## Scenario  ##
 
-Several KNX-compatible *actuators* for radiators valves and window shutters
+Several KNX-compatible *actuators* for radiator valves and window blinds
 are deployed across the rooms and the floors of a (virtual) building. We want
-to read the status of and send actuation commands to any of them, so as
-to change a radiator's valve or a window's shutter position.
+to read the status of, and send actuation commands to any of them, so as to
+change a radiator's valve or a window's blinds position.  Each actuator is
+assigned a *group address*, which is a triple (3-level-style) like:
 
-Each actuator is univocally identified by a group address... **[TO-DO: see doc
-in `ActuatorsRESTserver/knx_client_script.py`]**
+    <action>/<floor>/<bloc>
 
-Your **goal** is to write a Python-3-based client script which...  **[TO-DO:
-see the course's PDF doc**
+where the physical location is given by `<floor>/<bloc>`, whereas `<action>`
+specifies the command type. Notice that this is arbitrary, indeed the action
+can change but the target actuator is the same. Thus, something like
 
-A simulator is available to test your client script.
+    1/2/3
+
+may mean: do whatever is associated with action `1` (e.g., commands for a
+radiator valve) to the actuator(s) located at floor 2, bloc 3. The real action
+is specified further by the telegram's *payload*.
+
+Your **goal** is to complete a Python-3-based client script
+(`knx_client_script.py`) whose CLI is already defined -- see below. A
+simulator is available to test your client script -- see this repo's subdir
+`actuasim/`. Provided that the needed dependencies are installed, you can
+launch it with:
+
+    $ python actuasim.py
 
-**[TO-DO] more doc**
 
 ## Installation ##
 
@@ -29,29 +41,112 @@ You need to install:
 * the support library: `knxnet`
 * the simulator: `actuasim`
 
-See instructions in the corresponding sub-directories of this repository.
+This Git repo holds the relative project submodules. Clone it recursively:
+
+    $ git clone --recursive https://gitedu.hesge.ch/lsds/teaching/master/iot/knx.git
+
+Follow instructions (README) in the corresponding sub-directories of this
+repository, or at the following links:
+
+* `[knxnet/README](https://gitedu.hesge.ch/adrienma.lescourt/knxnet_iot/-/blob/master/README.md "knxnet readme")`
+* `[actuasim/README](https://gitedu.hesge.ch/adrienma.lescourt/actuasim_iot/-/blob/master/README.md "actuasim readme")`
 
-**[TO-DO] more doc**
 
 ## Develop  ##
 
-See the example script in `examples/knx_client_script.py`. You shall complete
-all parts marked as `@@@ TO BE COMPLETED @@@.
+You shall complete all parts marked as `@@@ TO BE COMPLETED @@@` in file
+`knx_client_script.py`. This CLI-based script and has two modes: raw and
+target. To know more about its usage:
+
+    $ knx_client_script.py --man
+
+Raw mode:
+
+    $ knx_client_script.py raw --help
 
-**[TO-DO] more doc**
+Target mode:
+
+    $ knx_client_script.py {blind,valve} --help
 
 
 ### Tests ###
 
-**[TO-DO]** Run the test suite...
+These are the tests that you shall perform against `actuasim`. The client
+script is configured to talk by default to a locally installed simulator
+instance.
+
+#### Radiator valves ####
+
+1. Open/close:
+
+        knx_client_script.py raw '0/4/1' 50 2 2
+        knx_client_script.py valve set '4/1' 50
+
+2. Read status:
+
+        knx_client_script.py raw '0/4/1' 0 1 0
+        knx_client_script.py valve get '4/1'
+
+#### Window blinds ####
+
+1. Full open:
+
+        knx_client_script.py raw '1/4/1' 0 1 2
+        knx_client_script.py blind open '4/1
+
+2. Full close:
+
+        knx_client_script.py raw '1/4/1'  1 1 2
+        knx_client_script.py blind close '4/1'
+
+3. Open/close:
+
+        knx_client_script.py raw '3/4/1' 50 2 2
+        knx_client_script.py blind set '4/1' 50
+
+2. Read status:
+
+        knx_client_script.py raw '4/4/1' 0 1 0
+        knx_client_script.py blind get '4/1'
 
 
 ## Maintenance  ##
 
-* Move here
+This section is for code *maintainers*. Students are however encouraged to
+read :-)
+
+Most tasks are available via the included `Makefile`. See:
+
+    $ make help
+
+Source code for the solution build and other sensitive files are encrypted. If
+you have just cloned this repo and need to work on sensitive stuff, **the
+first thing to do** is to run (your GPG public key must be registered by a
+repo's owner/maintainer -- see
+[here](https://githepia.hesge.ch/lsds-collab-test/cours-x/project-1/-/blob/master/README.md
+"Git-crypted test repo help")):
+
+    $ make unlock
+
+Unlocked content will be anyway encrypted on subsequent push operations. It
+won't work if the working dir is dirty! Labo's files usually come in two
+versions: `<whatever>.complete` and `<whatever>.incomplete`. At any time, only
+one of them (by default the `.incomplete`)is simlinked from a deployable
+`<whatever>` file -- i.e., this latter is a symlink and should never, ever by
+removed. Thus , you can just edit `<whatever>`
+
+It is good practice to have this repo labo-ready, thus *before* pushing new
+stuff, you should always call:
+
+    $ make reset
+
+To deploy a (complete) build/solution somewhere, use (remot user, host and path might
+need to be adapted):
+
+    $ [RUSER=... RHOST=... RPATH=...] make deploy
+
 
-    https://gitedu.hesge.ch/lsds/Smarthepia/-/blob/master/ActuatorsRESTserver/knx_client_script.py (to be encrypted)
-    https://gitedu.hesge.ch/lsds/Smarthepia/-/tree/master/ActuatorsRESTserver/tests
+### TO-DO ###
 
 * Provide an install script
 * Provide a test harness
diff --git a/examples/knx_client_script.py b/examples/knx_client_script.py
deleted file mode 100644
index 5ed34c1..0000000
--- a/examples/knx_client_script.py
+++ /dev/null
@@ -1 +0,0 @@
-# WORK IN PROGRESS: place holder for an example script"
diff --git a/knx_client_script.py b/knx_client_script.py
deleted file mode 100644
index cd9ca8b..0000000
--- a/knx_client_script.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# WORK IN PROGRESS: place holder for the *complete* solution to be crypted and
-# managed via git-crypt
diff --git a/knx_client_script.py b/knx_client_script.py
new file mode 120000
index 0000000..b8dbe19
--- /dev/null
+++ b/knx_client_script.py
@@ -0,0 +1 @@
+knx_client_script.py.incomplete
\ No newline at end of file
diff --git a/knx_client_script.py.complete b/knx_client_script.py.complete
new file mode 100755
index 0000000000000000000000000000000000000000..5f79210fbd9d966815e21bcce7651d5436018047
GIT binary patch
literal 19938
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
VYvn5&C-|)0BqP6L%aM9(696@x!L9%R

literal 0
HcmV?d00001

diff --git a/knx_client_script.py.incomplete b/knx_client_script.py.incomplete
new file mode 100755
index 0000000..19eeb94
--- /dev/null
+++ b/knx_client_script.py.incomplete
@@ -0,0 +1,596 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8; mode: Python -*-
+################################################################################
+"""knx_client_script.py -- demo client for UDP-based KNX deployments (ETS) @
+Smarthepia
+
+Usage:
+
+    'raw' mode:
+
+        python3 knx_client_script.py [OPTIONS] raw GROUP_ADDRESS PAYLOAD
+
+    'target' mode:
+
+        python3 knx_client_script.py [OPTIONS] TARGET COMMAND ADDRESS [VALUE]
+
+
+Arguments
+=========
+
+'raw' mode
+++++++++++
+
+    GROUP_ADDRESS
+        <str>. Triple of integers formatted as '<action>/<floor>/<bloc>' in
+        [0, 31]/[0, 7]/[0, 255]
+
+    PAYLOAD is a 3-int tuple 'DATA DATA_SIZE APCI':
+
+        DATA
+            <int>. Command data in [0, 255]
+
+        SIZE
+            <int>. Data size in bytes. Valid range is [1, 2]
+
+        APCI
+            <int>. Application Layer Protocol Control Information (service ID)
+            command: 0 for group value read (get), or 2 for group value write
+            (set)
+
+
+See L<Deployment configuration> for details.
+
+'target' mode
++++++++++++++
+
+    TARGET
+        <str>. Actuator/sensor class in ['blind', 'valve']
+
+    COMMAND
+        <str>. Command, depending on chosen target
+
+    ADDRESS
+        <str>. Pair of integers formatted as '<floor>/<bloc>' in [0, 7]/[0, 255]
+
+    VALUE
+        <int>. Only for command 'set', in percent.
+
+
+Global options
+==============
+
+    -i GATEWAY_IP_ADDRESS
+        <str>. Use '129.194.184.31' for the physical gateway @ Smarthepia, or
+        '127.0.0.1' for a local simulator
+
+    -p GATEWAY_UDP_PORT
+        <int>. Obvious
+
+    -c CONTROL_ENDPOINT
+        <str>. Control endpoint -- use '0.0.0.0:0' with the physycal gateway
+
+    -d DATA_ENDPOINT
+        <str>. Data endpoint -- use '0.0.0.0:0' with the physycal gateway
+
+    -l LOG_LEVEL
+        <str>. Logging level
+
+Call with '-h' to see help for all options.
+
+
+Deployment configuration
+========================
+
+Legend:
+
+* GADDR = GROUP_ADDRESS ("a/f/b"),
+* 'a' = action
+* 'f' = floor
+* 'b' = bloc
+* "don't-care" = any integer
+
+N.B. Action codes in GADDR for blinds and valves are not homogeneous!
+
+
+Blind's control
++++++++++++++++
+
+
+action              DATA                SIZE APCI  GADDR  status
+----------------------------------------------------------------
+full open (set)     0                   1    2     1/f/b  OK
+----------------------------------------------------------------
+full close (set)    1                   1    2     1/f/b  OK
+----------------------------------------------------------------
+read state (get)    (don't-care)        1    0     4/f/b  OK
+----------------------------------------------------------------
+partial open/close  [0...255]           2    2     3/f/b  OK
+(set)               0: 100% opened
+                    255: 100% closed
+----------------------------------------------------------------
+
+N.B. Different action codes are used for the various operations.
+
+
+Valve's control
++++++++++++++++
+
+
+action              DATA                SIZE APCI  GADDR  status
+----------------------------------------------------------------
+read state          (don't-care)        1    0     0/f/b  OK
+----------------------------------------------------------------
+partial open/close  [0...255]           2    2     0/f/b  OK
+                    0: 100% closed
+                    255: 100% opend
+----------------------------------------------------------------
+
+N.B. The same action code is used for all the operations.
+
+
+Examples
+========
+
+Raw mode
+++++++++
+
+Fully open (set) a blind @ floor 4, bloc 1 (notice a = 1):
+
+  $ python3 knx_client_script.py raw '1/4/1' 0 1 2
+
+
+Partially close/open [0,255] (set) a blind @ floor 4, bloc 1 (notice a =
+3):
+
+  $ python3 knx_client_script.py raw '3/4/1' 100 2 2
+
+
+Then, read (get) the blind status (notice a = 4, 1234 is used as "don't care"
+value):
+
+  $ python3 knx_client_script.py raw '4/4/1' 1234 1 0
+  ...
+  Result: 100
+
+
+Target mode
++++++++++++
+
+Fully open a blind @ floor 4, bloc 1:
+
+  $ python3 knx_client_script.py blind open '4/1'
+
+
+Partially open/close (set 30%) a blind @ floor 4, bloc 1:
+
+  $ python3 knx_client_script.py blind set '4/1' 30
+
+
+Then, read (get) the blind status:
+
+  $ python3 knx_client_script.py blind get '4/1'
+  ...
+  Result: 77
+
+
+Bugs
+====
+
+Mostly caused by the fact that SmartHepia deployment is not homogenous.
+
+1. With `actuasim`: a wrong action code might hang the protocol (no final
+tunnelling request).
+
+2. Invalid SIZE can crash `actuasim`. Should catch "bad frame" errors.
+
+
+To-Do
+=====
+
+* Clarify control and data endpoints
+
+
+See Also
+========
+
+* Smarthepia Web site L<http://lsds.hesge.ch/smarthepia/>
+"""
+################################################################################
+import argparse, socket, sys, logging
+from knxnet import *
+
+logger = None
+buf_size = 1024 # bytes
+
+cmmnd_ref = {
+    'blind' : {
+        'get' : {
+            'data'   : 1234,    # "don't-care"
+            'size'   : 1,
+            'apci'   : 0,
+            'action' : 4,
+        },
+        'set' : {
+            'data'   : None,    # from input as 'value'
+            'size'   : 2,
+            'apci'   : 2,
+            'action' : 3,
+        },
+        'open' : {
+            'data'   : 0,
+            'size'   : 1,
+            'apci'   : 2,
+            'action' : 1,
+        },
+        'close' : {
+            'data'   : 1,
+            'size'   : 1,
+            'apci'   : 2,
+            'action' : 1,
+        }
+    },
+    'valve' : {
+        'get' : {
+            'data'   : 1234,    # "don't-care"
+            'size'   : 1,
+            'apci'   : 0,
+            'action' : 0,
+        },
+        'set' : {
+            'data'   : None,    # from input  as 'value'
+            'size'   : 2,
+            'apci'   : 2,
+            'action' : 0,
+        },
+    }
+}
+
+################################################################################
+def validate_percent_int(string):
+    """Scream if input `string` is not an int in [0,100]
+
+    :param str string: the input value to validate
+
+    :returns int: the converted valid input value
+
+    :raises argparse.ArgumentTypeError: when the input is invalid
+    """
+    value=int(string)
+    if value < 0 or value > 100:
+        raise argparse.ArgumentTypeError("{}: must be integer in [0, 100]".format(string))
+
+    return value
+
+
+def build_target_command(target, args):
+    """Build a specific "target" command, using the global `cmmnd_ref` dict as a
+    reference.
+
+    :param target str: the target command
+
+    :param args namespace: the full argparse namespace
+
+    :returns list:
+
+        str gaddress: the KNX group address
+
+        str payload: the KNX payload
+
+    :raises ValueError: when the a value is not provided for a target called
+        with a 'set' command
+    """
+    logger.debug('args: {}'.format(args))
+
+    if target == 'raw':
+        return knxnet.GroupAddress.from_str(args.group_address), args.payload
+
+    # high-level command mode
+    cmnd_def = cmmnd_ref[target][args.command]
+    logger.debug('cmnd_def: {}'.format(cmnd_def))
+    gaddress = knxnet.GroupAddress.from_str(str(cmnd_def['action']) + '/' + args.address)
+
+    payload = [cmnd_def[k] for k in ('data', 'size', 'apci')]
+    if args.command == 'set':
+        if args.value == None:
+            raise ValueError("'set' command requires a 'value'")
+        # rescale
+        value = int(args.value/100*255)
+        # 'set' data
+        payload[0] = value
+
+    return gaddress, payload
+
+
+def send_knx_request(
+        dest_group_addr,
+        payload,         # data, data_size, apci
+        gateway_ip='127.0.0.1',
+        gateway_port='3671',
+        control_endpoint='127.0.0.1:3672',
+        data_endpoint='127.0.0.1:3672',
+):
+    """Send a request to a KNX gateway for a destination group address with a
+    payload.
+
+    Arguments
+    +++++++++
+
+    :param dest_group_addr str: group address like 'ACTION/FLOOR/BLOCK'
+
+    :param payload list: a tuple [DATA, DATA_SIZE, APCI]
+
+
+    Keyword args
+    ++++++++++++
+
+    :param gateway_ip str: the gateway's IP address
+
+    :param gateway_port str: the gateway's IP PORT
+
+    :param control_endpoint str: the control endpoint as 'IP-ADDRESS:PORT'
+
+    :param data_endpoint str: the data endpoint as 'IP-ADDRESS:PORT'
+
+    :returns list: (status, reply)
+
+        bool status: success/failure code
+
+        str reply: a numerical value for get commands or a textual status code
+            for anything else
+    """
+    data, data_size, apci = payload
+
+    gateway_port = int(gateway_port)
+
+    control_endpoint = control_endpoint.split(':')
+    control_endpoint = tuple([control_endpoint[0], int(control_endpoint[1])])
+    data_endpoint = data_endpoint.split(':')
+    data_endpoint = tuple([data_endpoint[0], int(data_endpoint[1])])
+
+    local_ip_addr = control_endpoint[0]
+    local_udp_port = control_endpoint[1]
+
+    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    sock.bind(('', local_udp_port))
+
+    # -> Connection request
+    conn_req = knxnet.create_frame(
+        knxnet.ServiceTypeDescriptor.CONNECTION_REQUEST,
+        control_endpoint,
+        data_endpoint
+    )
+    logger.debug('>>> CONNECTION_REQUEST:\n{}'.format(conn_req))
+    sock.sendto(conn_req.frame, (gateway_ip, gateway_port))
+
+    # <- Connection response
+    logger.debug('waiting for a connection response (CONNECTION_RESPONSE -> conn_resp)')
+    data_recv, addr = sock.recvfrom(buf_size)
+    conn_resp = knxnet.decode_frame(data_recv)
+    logger.info('reponse status: {}'.format(conn_resp.status))
+    logger.info('reponse data endpoint: {}'.format(conn_resp.data_endpoint))
+
+    ############################################################################
+    # @@@ TO BE COMPLETED @@@
+    # >>> START OF YOUR WORK
+    ############################################################################
+
+    # So far, only the first 2 steps of a full
+    # protocol round were done (CONNECTION_REQUEST/RESPONSE).  Keep in mind
+    # that:
+
+    # you have to ckeck status codes to see if the the command was correctly
+    # understood, and _disconnect_ otherwise (DISCONNECT_REQUEST). Then again
+    # check if that went through;
+
+    # the `acpi` tells you if you're handling a get (read) or a set (write)
+    # command.
+
+    # <<< END OF YOUR WORK
+    ############################################################################
+
+    return False, 'Error'
+
+
+################################################################################
+if __name__ == "__main__":
+    logging.basicConfig(
+        # level=logging.WARNING,
+        format='%(levelname)-8s %(message)s'
+    )
+    logger = logging.getLogger('knx_client')
+
+    ############################################################################
+    # main parser
+    ############################################################################
+    parser = argparse.ArgumentParser(
+        description='Demo client for UDP-based KNX deployments (ETS) @ Smarthepia',
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter
+    )
+
+    parser.add_argument(
+        '-m', '--manual',
+        dest='manual',
+        action='store_true',
+        help='print the full documentation'
+    )
+
+    parser.add_argument(
+        '-i', '--gateway-ip-address',
+        dest='gateway_ip_address',
+        type=str,
+        default='127.0.0.1',
+        help="Gateway's IP address"
+    )
+
+    parser.add_argument(
+        '-p', '--gateway-udp-port',
+        dest='gateway_udp_port',
+        type=str,
+        default='3671',
+        help="Gateway's UDP port"
+    )
+
+    parser.add_argument(
+        '-c', '--control-endpoint',
+        dest='control_endpoint',
+        type=str,
+        default='127.0.0.1:3672',
+        help="Control endpoint -- use '0.0.0.0:0' for NAT"
+    )
+
+    parser.add_argument(
+        '-d', '--data-endpoint',
+        dest='data_endpoint',
+        type=str,
+        default='127.0.0.1:3672',
+        help="Data endpoint -- use '0.0.0.0:0' for NAT"
+    )
+
+    parser.add_argument(
+        '-l', '--log-level',
+        dest='log_level',
+        type=str,
+        default='ERROR',
+        choices=(
+            'CRITICAL',
+            'ERROR',
+            'WARNING',
+            'INFO',
+            'DEBUG',
+        ),
+        help="Logging level"
+    )
+
+    ############################################################################
+    # subcommands
+    ############################################################################
+    subparsers = parser.add_subparsers(
+        title='targets',
+        dest='subparser_name',
+        help='Target actuator/sensor ("raw" is for low-level KNX-style)'
+    )
+
+    # @raw target
+    parser_raw = subparsers.add_parser(
+        'raw',
+        help='send low-level KNX-style commands, such as "-g ACTION/FLOOR/BLOCK DATA SIZE ACPI"',
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter
+    )
+
+    parser_raw.add_argument(
+        'group_address',
+        type=str,
+        help='KNX group address "<action>/<floor>/<bloc>"'
+    )
+
+    parser_raw.add_argument(
+        'payload',
+        # no metavar, else choices won't be proposed on -h
+        type=int,
+        nargs=3,
+        help="3-int tuple 'DATA DATA_SIZE APCI'"
+    )
+
+    # @blind target
+    parser_blind = subparsers.add_parser(
+        'blind',
+        help='send high-level commands to blinds, such as "get FLOOR/BLOCK"',
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter
+    )
+
+    parser_blind.add_argument(
+        'command',
+        type=str,
+        choices=('open', 'close', 'get', 'set'),
+        help='Blind command (get == read, set == write)'
+    )
+
+    parser_blind.add_argument(
+        'address',
+        type=str,
+        help='Blind address "<floor>/<bloc>"'
+    )
+
+    parser_blind.add_argument(
+        'value',
+        nargs='?',
+        type=validate_percent_int,
+        help='Set value percent [0, 100]'
+    )
+
+    # @valve target
+    parser_valve = subparsers.add_parser(
+        'valve',
+        help='send high-level commands to valves, such as "get FLOOR/BLOCK"',
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter
+    )
+
+    parser_valve.add_argument(
+        'command',
+        type=str,
+        choices=('get', 'set'),
+        help='Valve command (get == read, set == write)'
+    )
+
+    parser_valve.add_argument(
+        'address',
+        type=str,
+        help='Valve address "<floor>/<bloc>"'
+    )
+
+    parser_valve.add_argument(
+        'value',
+        nargs='?',
+        type=validate_percent_int,
+        help='Set value percent [0,100]'
+    )
+
+    ############################################################################
+
+    args = parser.parse_args()
+
+    if args.manual:
+        print(__doc__)
+        sys.exit(0)
+
+    logger.debug("target: {}".format(args.subparser_name))
+    target = args.subparser_name
+    if not target:
+        parser.print_usage()
+        sys.exit('\nPlease provide a "target"')
+
+    logger.setLevel(args.log_level)
+
+    # parse high-level command
+    try:
+        dest_addr, payload = build_target_command(target, args)
+    except ValueError as e:
+        sys.exit("Can't build target command: {}".format(e))
+
+    logger.info(
+        'going to send command: {} (group address) {} (payload)'.format(dest_addr, payload)
+    )
+
+    reply = send_knx_request(
+        dest_addr,
+        payload,
+        gateway_ip=args.gateway_ip_address,
+        gateway_port=args.gateway_udp_port,
+        control_endpoint=args.control_endpoint,
+        data_endpoint=args.data_endpoint,
+    )
+
+    logger.debug('reply: {}'.format(reply))
+
+    status = 0 if reply[0] else 1
+    result = reply[1]
+    try:
+        result = int(result)
+        result = '{} ({}%)'.format(result, int(result/255*100))
+    except ValueError:
+        pass
+
+    print('Result: {}'.format(result))
+
+    sys.exit(status)
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..ae860f4
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,58 @@
+Tests for smarthepia deployment
+===============================
+
+KNX gateway address:    129.194.184.31
+Video monitor:          http://129.194.184.223/video1.mjpg
+Terminal login:         ssh 129.194.186.252
+
+First, set a command alias ;-)
+
+    $ alias knx='knx_client_script.py -i 129.194.184.31 -c 0.0.0.0:0 -d 0.0.0.0:0'
+
+
+#### Radiator valves ####
+
+1. Open/close:
+
+        knx raw '0/4/10' 50 2 2
+        knx valve set '4/10' 50
+
+2. Read status:
+
+        knx raw '0/4/10' 0 1 0
+        knx valve get '4/10'
+
+#### Window blinds ####
+
+1. Full open:
+
+        knx raw '1/4/10' 0 1 2
+        knx blind open '4/1
+
+2. Full close:
+
+        knx raw '1/4/10'  1 1 2
+        knx blind close '4/10'
+
+3. Open/close:
+
+        knx raw '3/4/10' 50 2 2
+        knx blind set '4/10' 50
+
+2. Read status:
+
+        knx raw '4/4/10' 0 1 0
+        knx blind get '4/10'
+
+
+Bugs
+====
+
+Read status returns the actual value + 1.
+
+
+To do
+=====
+
+* Scriptify the above tests ;-)
+* Provide a test harness
-- 
GitLab