Skip to content

Commit

Permalink
Major refactoring of codebase
Browse files Browse the repository at this point in the history
- Code linting and optimization
- Better integration of mqtt (not only publishing but receiving states)
- mqtt discovery to send of/off commands over mqtt
- Overall interface review and adjustment
  • Loading branch information
Dennis Muth committed Mar 16, 2019
1 parent dab4eee commit 557252b
Show file tree
Hide file tree
Showing 31 changed files with 1,414 additions and 310 deletions.
570 changes: 570 additions & 0 deletions .pylintrc

Large diffs are not rendered by default.

29 changes: 16 additions & 13 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
FROM arm32v7/python:3.6-slim-stretch
# FROM arm32v7/python:3.5-slim-stretch
FROM python:3.5-slim-stretch

# Setup workdir
ENV WORKDIR /rpi-433rc

# Setting correct entrypoint
# ENV FLASK_APP ${WORKDIR}/rpi433rc/app.py
ENV PYTHONPATH=${WORKDIR}
ENV FLASK_MODULE=rpi433rc.app:app

RUN mkdir -p ${WORKDIR} && \
cd ${WORKDIR}

# Install rest-api wrapper for rpi-rf
COPY . ${WORKDIR}
ENV CONFIG_DIR=/conf

RUN apt-get update -yy && \
apt-get install -yy libc6-dev gcc

RUN mkdir -p ${WORKDIR}
WORKDIR ${WORKDIR}

# Copy the requirements file over to be a single layer
# This prevents that the layer is rebuild when ANY file has changed
COPY requirements.txt .

RUN pip3 install \
--extra-index-url https://www.piwheels.hostedpi.com/simple \
-r ${WORKDIR}/requirements.txt
-r requirements.txt


# Install the rest of the application
COPY . .

# Re-copy the entrypoint.sh to the root
COPY ./entrypoint.sh /entrypoint.sh
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Entrypoint defaults to bash
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ setup:

lint:
flake8 --exclude=.tox --max-line-length 120 --ignore=E722 --ignore=E402 $(SOURCE_PATH)
pylint $(SOURCE_PATH)

test:
pytest --verbose --color=yes \
Expand Down
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,9 @@ Where `device1` and `device2` are the actual names of the devices. Be creative ;

Easy one, too:

# This command
docker run --rm \
--name rpi-433rc \
-p "5555:5000" \
-e CONFIG_DIR=/conf \
-e GPIO_OUT=17 \
-v <path/to/your/device.json>:/conf/devices.json \
--privileged --cap-add SYS_RAWIO --device=/dev/mem \
Expand All @@ -69,3 +67,44 @@ Easy one, too:
You can change the GPIO_OUT if you are using a different one than me.
Nicely done. Thanks to port forwarding you should see the swagger ui when navigating to the url [http://<raspi-ip>:5555](http://<raspi-ip>:5555).
Feel free to try the different endpoints.

## Enable mqtt support

You can enable support for state publication to a mqtt broker. Start the container as follows:

docker run --rm \
--name rpi-433rc \
-p "5555:5000" \
-v <path/to/your/device.json>:/conf/devices.json \
--link mqtt:mqtt \
-e GPIO_OUT=17 \
-e MQTT_HOST=mqtt \
-e MQTT_PORT=1883 \
-e MQTT_ROOT=rc433 \
--privileged --cap-add SYS_RAWIO --device=/dev/mem \
hazard/rpi-433rc:latest serve
Last but no least, you can enable mqtt discovery for [homeassistant](https://www.home-assistant.io). You can find the
documentation about mqtt discovery [here](https://www.home-assistant.io/docs/mqtt/discovery/).

docker run --rm \
--name rpi-433rc \
-p "5555:5000" \
-v <path/to/your/device.json>:/conf/devices.json \
--link mqtt:mqtt \
-e GPIO_OUT=17 \
-e MQTT_HOST=mqtt \
-e MQTT_PORT=1883 \
-e MQTT_ROOT=rc433 \
-e MQTT_DISCOVERY=1 \
--privileged --cap-add SYS_RAWIO --device=/dev/mem \
hazard/rpi-433rc:latest serve
Please configure your `MQTT_ROOT` to match your discovery topic root in homeassistant. All devices will be published
as switches matching the following topic pattern:

<MQTT_ROOT>/switch/<DEVICE_NAME>/[state, config, set]

Topic `state` is for state publications, `config` is for automatic entity configuration (will be done automatically) and
`set` is the command topic where homeassistant (or others) can publish `on` / `off` to switch the device to the specified
state.
2 changes: 1 addition & 1 deletion entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ case ${1} in
exec rpi-rf_receive "$@"
;;
serve)
exec gunicorn --workers 1 --bind 0.0.0.0:5000 ${FLASK_MODULE}
exec ./run.sh
;;
*)
exec "$@"
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
bumpversion
pip-tools
pylint
pytest
pytest-cov
pytest-mock
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ jsonschema==2.6.0 # via flask-restplus
markupsafe==1.0 # via jinja2
paho-mqtt==1.4.0
pytz==2017.3 # via flask-restplus
rpi-rf==0.9.6
rpi.gpio==0.6.3 # via rpi-rf
rpi-rf==0.9.7
rpi.gpio==0.6.5 # via rpi-rf
schema==0.6.7
six==1.11.0 # via flask-restplus
werkzeug==0.14.1 # via flask
4 changes: 4 additions & 0 deletions rpi433rc/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
"""Initializes the flask namespaces."""
# pylint: skip-file

from flask_restplus import Api

from ..config import VERSION

# pylint: disable=invalid-name
api = Api(
title='RPi433',
version=VERSION,
Expand Down
12 changes: 12 additions & 0 deletions rpi433rc/api/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Initializes the flask app."""
# pylint: skip-file

from flask import Flask

app = Flask(__name__)

from .flaskutil.routing import OnOffConverter
app.url_map.converters['on_off'] = OnOffConverter

from . import api
api.init_app(app)
34 changes: 22 additions & 12 deletions rpi433rc/api/devices.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
"""Device related routes."""

from flask_restplus import Resource, Namespace, fields

from .flaskutil import fields as _fields
from .flaskutil.auth import requires_auth
from ..business.devices import UnknownDeviceError
from ..business.rc433 import UnsupportedDeviceError

api = Namespace('devices', description='Socket device related operations')
api = Namespace('devices', description='Socket device related operations') # pylint: disable=invalid-name


@api.errorhandler(UnknownDeviceError)
@api.errorhandler(UnsupportedDeviceError)
def unknown_device(error):
"""Unknown device error serializer."""
return {'message': str(error), 'value': 'device_name'}, 400


state = api.model('State', {
STATE = api.model('State', {
'state': _fields.OnOff,
'result': fields.Boolean
})

device = api.model('Device', {
DEVICE = api.model('Device', {
'device_name': fields.String(attribute="device.device_name"),
'type': fields.String(attribute=lambda o: str(o.device.__class__.__name__) if hasattr(o, 'device') else None),
'type': fields.String(attribute=lambda o: (str(o.device.__class__.__name__)
if hasattr(o, 'device') else None)),
'configuration': _fields.Dict(attribute="device.configuration"),
'state': _fields.OnOff
})
Expand All @@ -30,29 +34,35 @@ def unknown_device(error):
@api.route('/')
@api.route('/list')
class DeviceList(Resource):
"""Endpoint to list devices."""
@requires_auth
@api.marshal_with(device)
def get(self):
@api.marshal_with(DEVICE)
def get(self): # pylint: disable=no-self-use
"""Implements get operation."""
from . import device_db
devices = device_db.list()
return devices


@api.route('/<string:device_name>')
class DeviceLookup(Resource):
"""Endpoint to lookup a specific device."""
@requires_auth
@api.marshal_with(device)
def get(self, device_name):
@api.marshal_with(DEVICE)
def get(self, device_name): # pylint: disable=no-self-use
"""Implements get operation."""
from . import device_db
return device_db.lookup(device_name)
return device_db.lookup(device_name=device_name)


@api.route('/<string:device_name>/<on_off:on_off>')
class DeviceSwitch(Resource):
"""Endpoint to switch a specific device to a given state."""
@requires_auth
@api.marshal_with(state)
def get(self, device_name, on_off):
@api.marshal_with(STATE)
def get(self, device_name, on_off): # pylint: disable=no-self-use
"""Implements get operation."""
from . import device_db

res = device_db.switch(device_name, on_off)
res = device_db.switch(on_off, device_name=device_name)
return {'state': on_off, 'result': res}
9 changes: 6 additions & 3 deletions rpi433rc/api/flaskutil/auth.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Provides basic authentication stuff for flask."""

from functools import wraps

from flask import request, Response
Expand All @@ -19,13 +21,14 @@ def auth_401():
{'WWW-Authenticate': 'Basic realm="Login Required"'})


def requires_auth(f):
@wraps(f)
def requires_auth(fun):
"""Decorator to mark endpoints that they require authentication."""
@wraps(fun)
def decorated(*args, **kwargs):
from ...config import AUTH_USER
if AUTH_USER is not None:
auth = request.authorization
if not auth or not validate_auth(auth.username, auth.password):
return auth_401()
return f(*args, **kwargs)
return fun(*args, **kwargs)
return decorated
10 changes: 8 additions & 2 deletions rpi433rc/api/flaskutil/fields.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
"""Provides some additional field validation and serialization for flask."""

from flask_restplus import fields


class Dict(fields.Raw):
"""Dictionary serializer."""
def format(self, value):
"""Formats the given value according to the fields strategy."""
if isinstance(value, dict):
return value

raise fields.MarshallingError("Can not marshal dictionary")


class OnOff(fields.Boolean):
"""On/Off serializer for boolean."""
__schema_type__ = 'string'

def format(self, value):
b = super().format(value)
return "on" if b else "off"
"""Formats the given value according to the fields strategy."""
boolean = super().format(value)
return "on" if boolean else "off"
4 changes: 4 additions & 0 deletions rpi433rc/api/flaskutil/routing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Provides basic converters to flask."""

from werkzeug.routing import BaseConverter


Expand All @@ -19,7 +21,9 @@ class OnOffConverter(BaseConverter):
"""

def to_python(self, value):
"""Converts the python string (that represents on/off) to a boolean."""
return value.lower() == 'on'

def to_url(self, value):
"""Transforms the on/off to an url friendly value."""
return BaseConverter.to_url(self, value='on' if value else 'off')
12 changes: 8 additions & 4 deletions rpi433rc/api/send.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
"""Provides endpoints to send bare codes to devices via 433 mhz hardware."""

from flask_restplus import Resource, Namespace, fields

from .flaskutil.auth import requires_auth

api = Namespace('send', description='Remote control related operations')
api = Namespace('send', description='Remote control related operations') # pylint: disable=invalid-name


code = api.model('Code', {
CODE = api.model('Code', {
'code': fields.Integer,
'result': fields.Boolean
})


@api.route('/<int:code>')
class SendCode(Resource):
"""Endpoint to send bare 433mhz codes to devices in range."""
@requires_auth
@api.marshal_with(code)
def get(self, code):
@api.marshal_with(CODE)
def get(self, code): # pylint: disable=no-self-use
"""Implements get operation."""
from . import device_db
return {'code': code, 'result': device_db.rc433.send_code(code)}
8 changes: 6 additions & 2 deletions rpi433rc/api/version.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"""Provides version related endpoints."""

from flask_restplus import Resource, Namespace

api = Namespace('version', description='Version')
api = Namespace('version', description='Version') # pylint: disable=invalid-name


@api.route('/')
class Version(Resource):
def get(self):
"""Endpoint that provides the current version of the api."""
def get(self): # pylint: disable=no-self-use
"""Implements get operation."""
from ..config import VERSION
return {'version': VERSION}
16 changes: 0 additions & 16 deletions rpi433rc/app.py

This file was deleted.

Loading

0 comments on commit 557252b

Please sign in to comment.