diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..07f9fb1d25381955bfa006566ad44690054d19b0
--- /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 185a74b6981bfb36eb9d63029afccf1aa4a1f178..bb72878e595da0c449febd0afdc9e387494a4cf3 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 0000000000000000000000000000000000000000..b98bd318267a62f32a425b7bd74d220b69047f1c
--- /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 0b271c80ed26202013bb294d5afa4d922d9817d1..6e0ddff8f8ac99da691522a7d4d25150c73c453f 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 5ed34c1711a97692416fcef84d50e422108c3b37..0000000000000000000000000000000000000000
--- 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 cd9ca8b66a85ee79f44e737c955c4ddd21e74801..0000000000000000000000000000000000000000
--- 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 0000000000000000000000000000000000000000..b8dbe1997e1b5f7d2a2eb35618cb4de91b2d5f1e
--- /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
Binary files /dev/null and b/knx_client_script.py.complete differ
diff --git a/knx_client_script.py.incomplete b/knx_client_script.py.incomplete
new file mode 100755
index 0000000000000000000000000000000000000000..19eeb940334500d2f9934a337de4c4a1268c3e7b
--- /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 0000000000000000000000000000000000000000..ae860f4219f9ce2dad9a13bd2e8211edf409d856
--- /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