Skip to content
Snippets Groups Projects
Commit 5d28cd7f authored by marcoemi.poleggi's avatar marcoemi.poleggi
Browse files

Doc revision. Added makefile, ecnrypted solution, tests, etc

parent 9f7ce803
Branches
No related tags found
No related merge requests found
*.py.* gitlab-language=python
knx_client_script.py.complete filter=git-crypt diff=git-crypt
[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
Makefile 0 → 100644
.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))
......@@ -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
# WORK IN PROGRESS: place holder for an example script"
# WORK IN PROGRESS: place holder for the *complete* solution to be crypted and
# managed via git-crypt
knx_client_script.py.incomplete
\ No newline at end of file
File added
#!/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)
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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment