From 2f6ee21f11329eafacab6d33b2cd6e982f4d8079 Mon Sep 17 00:00:00 2001
From: Marcel Beyer <marcel.beyer@it-maker.eu>
Date: Mon, 20 May 2019 21:05:16 +0200
Subject: [PATCH] remote sensors with api

---
 app.py                                        | 62 ++++++++++++++++++-
 forms.py                                      | 10 ++-
 .../versions/453b00e0ca8e_remote_sensors.py   | 28 +++++++++
 model.py                                      | 34 +++++++++-
 templates/new_remote_sensor.html              | 37 +++++++++++
 templates/sensor.html                         | 17 +++++
 6 files changed, 182 insertions(+), 6 deletions(-)
 create mode 100644 migrations/versions/453b00e0ca8e_remote_sensors.py
 create mode 100644 templates/new_remote_sensor.html

diff --git a/app.py b/app.py
index 52dc6cb..e294563 100644
--- a/app.py
+++ b/app.py
@@ -1,8 +1,9 @@
 import datetime
+import hashlib
 import json
 from functools import wraps
 
-from flask import Flask, render_template, redirect, url_for, request, flash
+from flask import Flask, render_template, redirect, url_for, request, flash, abort
 from flask_navigation import Navigation
 from sqlalchemy import and_, or_
 
@@ -35,8 +36,8 @@ def create_app():
 def gen_nav():
     nav.Bar('main', [
         nav.Item('Dashboard', 'dashboard'),
-        nav.Item('Sensors', 'index', items=[nav.Item(s.name, 'sensor', {'sensorID': s.id}) for s in get_sensors()]+
-            [nav.Item('+ new', 'new_sensor')]
+        nav.Item('Sensors', 'index', items=[nav.Item(s.name, 'sensor', {'sensorID': s.id}) for s in get_sensors()] +
+            [nav.Item('+ new', 'new_sensor'), nav.Item('+ new remote', 'new_remote_sensor')]
         ),
         nav.Item('Relays', 'index', items=[nav.Item(r.name, 'relay', {'relayID': r.id}) for r in get_relays()] +
             [nav.Item('+ new', 'new_relay')]
@@ -100,6 +101,7 @@ def sensor(sensorID):
 @app.route('/sensor/<int:sensorID>/delete')
 @with_navigation
 def del_sensor(sensorID):
+    sensor = model.Sensor.query.get(sensorID)
     model.SensorValue.query.filter_by(sensor_id=sensorID).delete()
     model.Condition_sensorCompare.query.filter( or_(model.Condition_sensorCompare.sensor1==sensorID,
                                                     model.Condition_sensorCompare.sensor2==sensorID)).delete()
@@ -107,6 +109,10 @@ def del_sensor(sensorID):
                                                    model.Condition_sensorDiffCompare.sensor2 == sensorID)).delete()
     model.Condition_valueCompare.query.filter_by(sensor=sensorID).delete()
 
+    if sensor.is_remote:
+        id = sensor.address1w[4:]
+        model.RemoteSensor.query.filter_by(id=id).delete()
+
     model.Sensor.query.filter_by(id=sensorID).delete()
     model.db.session.commit()
     flash('Deletion successfull.')
@@ -127,6 +133,22 @@ def new_sensor():
 
     return render_template('new_sensor.html', form=form)
 
+@app.route('/sensor/new_remote', methods=['GET', 'POST'])
+@with_navigation
+def new_remote_sensor():
+    form = forms.NewRemoteSensorForm(request.form)
+
+    if request.method == 'POST' and form.validate():
+        rem_sensor = model.RemoteSensor()
+        model.db.session.add(rem_sensor)
+        model.db.session.flush()
+        sensor = model.Sensor('rem-{}'.format(rem_sensor.id), form.name.data)
+        model.db.session.add(sensor)
+        model.db.session.commit()
+        flash('Sensor created successfully.')
+
+    return render_template('new_remote_sensor.html', form=form)
+
 @app.route('/relay/<int:relayID>/', methods=['GET', 'POST'])
 @with_navigation
 def relay(relayID):
@@ -346,6 +368,40 @@ def new_user():
 
     return render_template('new_user.html', form=form)
 
+@app.route('/api/remotesensor/<int:sensorID>', methods=['GET'])
+def api_remote_sensor_new_value(sensorID):
+    sensor = model.Sensor.query.get(sensorID)
+
+    signature = request.args.get("signature","")
+    value = request.args.get("value","")
+
+    if sensor.is_remote:
+        addr = sensor.address1w[4:]
+        remote_sensor = model.RemoteSensor.query.get(addr)
+
+        # check if value is float
+        try:
+            float(value)
+        except ValueError:
+            return abort(400)
+
+        # check signature
+        s = remote_sensor.key + value
+        s = hashlib.sha256(s.encode()).hexdigest()
+
+        if s != signature:
+            # signature was wrong
+            return abort(403)
+
+        # save value
+        sv = model.SensorValue(sensor, float(value))
+        model.db.session.add(sv)
+        model.db.session.commit()
+
+        return "OK"
+    else:
+        return abort(400)
+
 @app.route('/logout/')
 def logout():
     return "logout"
diff --git a/forms.py b/forms.py
index 58157a0..8efe61e 100644
--- a/forms.py
+++ b/forms.py
@@ -19,6 +19,14 @@ def validate_address1w(form, field):
         if sensors is not None:
             raise ValidationError("Sensor is already configured.")
 
+class NewRemoteSensorForm(FlaskForm):
+    name = StringField('Name', validators=[DataRequired(message="No name given")])
+
+    def validate_name(form, field):
+        sensors = Sensor.query.filter_by(name=field.data).first()
+        if sensors is not None:
+            raise ValidationError("Name already in use.")
+
 class NewRelayForm(FlaskForm):
     name = StringField('Name', validators=[DataRequired(message="No name given")])
     port = SelectField('Port', validators=[DataRequired(message="No port specified")], coerce=int)
@@ -83,4 +91,4 @@ class CreateUserForm(FlaskForm):
     def validate_name(form, field):
         u = User.query.filter_by(username=field.data).first()
         if u is not None:
-            raise ValidationError("Username already in use.")
\ No newline at end of file
+            raise ValidationError("Username already in use.")
diff --git a/migrations/versions/453b00e0ca8e_remote_sensors.py b/migrations/versions/453b00e0ca8e_remote_sensors.py
new file mode 100644
index 0000000..4707cd9
--- /dev/null
+++ b/migrations/versions/453b00e0ca8e_remote_sensors.py
@@ -0,0 +1,28 @@
+"""remote sensors
+
+Revision ID: 453b00e0ca8e
+Revises: 3cf7480569d1
+Create Date: 2019-05-20 19:37:40.689172
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '453b00e0ca8e'
+down_revision = '3cf7480569d1'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.create_table('remotesensor',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('key', sa.String(), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+
+
+def downgrade():
+    op.drop_table('remotesensor')
diff --git a/model.py b/model.py
index f0a9e12..5321e5b 100644
--- a/model.py
+++ b/model.py
@@ -1,4 +1,6 @@
 import datetime
+import random
+import string
 
 from flask_sqlalchemy import SQLAlchemy
 from sqlalchemy.ext.hybrid import hybrid_property
@@ -59,6 +61,15 @@ def check_password(self, password):
     def __repr__(self):
         return '<User {}>'.format(self.username)
 
+class RemoteSensor(db.Model):
+    __tablename__ = 'remotesensor'
+    id = db.Column(db.Integer, primary_key=True)
+    key = db.Column(db.String, nullable=False)
+
+    def __init__(self):
+        # After creating this remote sensor, a new Sensor('rem-{RemoteSensor.id}', '{name}') has to be created
+        self.key = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(15))
+
 class Sensor(db.Model):
     __tablename__ = 'sensor'
     id = db.Column(db.Integer, primary_key=True)
@@ -69,9 +80,28 @@ def __init__(self, address1w, name):
         self.address1w = address1w
         self.name = name
 
+    @hybrid_property
+    def is_remote(self):
+        return self.address1w.startswith('rem-')
+
+    @hybrid_property
+    def remoteSensor(self):
+        # returns object of remote sensor, if this sensor is a remote sensor, otherwise None
+        if self.is_remote:
+            id = self.address1w[4:]
+            return RemoteSensor.query.get(id)
+        else:
+            return None
+
     @hybrid_property
     def value(self):
-        return get_sensor_value(self.address1w)
+        if self.is_remote is True:
+            # sensor is a remote sensor, get last known value from db
+            value = SensorValue.query.filter_by(sensor_id=self.id).order_by(SensorValue.time.desc()).first()
+            return value.value if value is not None else False
+        else:
+            # sensor is connected 1w-sensor
+            return get_sensor_value(self.address1w)
 
 class SensorValue(db.Model):
     __tablename__ = 'sensorValue'
@@ -322,4 +352,4 @@ def __str__(self):
     @hybrid_property
     def fulfilled(self):
         time = datetime.datetime.now().time()
-        return self.start_time <= time <= self.end_time
\ No newline at end of file
+        return self.start_time <= time <= self.end_time
diff --git a/templates/new_remote_sensor.html b/templates/new_remote_sensor.html
new file mode 100644
index 0000000..191c7d1
--- /dev/null
+++ b/templates/new_remote_sensor.html
@@ -0,0 +1,37 @@
+{% extends "_base.html" %}
+{% set title='New sensor' %}
+{% set heading='Configure new sensor' %}
+
+{% block breadcumb %}
+    <ol class="breadcrumb">
+        <li>Home</li>
+        <li>Sensors</li>
+        <li class="active">New remote</li>
+    </ol>
+{% endblock %}
+
+{% block content %}
+    <p>
+    For remote sensors it is possible to push new temperature values with an HTTP API.
+    </p>
+    <form action="" method="post">
+    {{ form.csrf_token }}
+    <div class="row">
+        <div class="col-md-2">
+            {{ form.name.label }}
+            {% if form.name.errors %}<br><span class="text-danger">{{ form.name.errors[0] }}</span>{% endif %}
+        </div>
+        <div class="col-md-4">
+            {{ form.name(class_="form-control") }}
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-2">
+        </div>
+        <div class="col-md-4">
+            <button type="submit" class="btn btn-primary">Create</button>
+        </div>
+    </div>
+    </form>
+
+{% endblock %}
diff --git a/templates/sensor.html b/templates/sensor.html
index 7b30d51..155ba93 100644
--- a/templates/sensor.html
+++ b/templates/sensor.html
@@ -58,6 +58,23 @@
         </div>
       </div>
 
+    {% if sensor.is_remote %}
+    <div class="row">
+        <div class="col-md-12">
+            <p>
+                This sensor is a remote sensor. Values are not meassured on this device but received with API calls.
+            </p>
+            <p>
+                <b>API Credentials:</b><br>
+                ID: <tt>{{ sensor.id }}</tt><br>
+                Key: <tt>{{ sensor.remoteSensor.key }}</tt><br>
+                Send HTTP-GET-Requests to: <tt>{{ url_for('api_remote_sensor_new_value',sensorID=sensor.id) }}?value={value}&signature={signature}</tt><br>
+                where {value} is the current temperature in &deg;C as a float and signature is the SHA256 hash of the value concatenated with the key.
+            </p>
+        </div>
+    </div>
+    {% endif %}
+
 {% endblock %}
 {% block extraJS %}
     <script type="text/javascript">