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