From 43467eedbc0699e220fb895d955b5a75192d9f83 Mon Sep 17 00:00:00 2001 From: Frank Edwards Date: Mon, 30 Jul 2018 21:54:35 +1000 Subject: [PATCH] 0.4.3 Add Azure MQTT and Text File interfaces --- .gitignore | 3 + Change Log.md | 3 + cmd/floracmd | 2 +- floranet/appserver/azure_iot_https.py | 10 +- floranet/appserver/azure_iot_mqtt.py | 253 ++++++++++++++++++ floranet/appserver/file_text_store.py | 93 +++++++ floranet/commands/interface.py | 30 ++- ...d3d3b1e_create_appif_azureiotmqtt_table.py | 32 +++ ...ed30f62f76_create_appif_file_text_store.py | 29 ++ floranet/database.py | 5 +- floranet/imanager.py | 24 +- floranet/models/appinterface.py | 8 +- floranet/test/integration/test_azure_https.py | 6 +- floranet/test/integration/test_azure_mqtt.py | 70 +++++ .../test/integration/test_file_text_store.py | 58 ++++ .../test/unit/web/test_restappinterface.py | 11 +- floranet/web/rest/appinterface.py | 42 ++- floranet/web/rest/application.py | 8 +- setup.py | 7 +- 19 files changed, 654 insertions(+), 40 deletions(-) create mode 100644 floranet/appserver/azure_iot_mqtt.py create mode 100644 floranet/appserver/file_text_store.py create mode 100644 floranet/data/alembic/versions/09a18d3d3b1e_create_appif_azureiotmqtt_table.py create mode 100644 floranet/data/alembic/versions/d5ed30f62f76_create_appif_file_text_store.py create mode 100644 floranet/test/integration/test_azure_mqtt.py create mode 100644 floranet/test/integration/test_file_text_store.py diff --git a/.gitignore b/.gitignore index efb4d77..ee0d9b9 100644 --- a/.gitignore +++ b/.gitignore @@ -191,6 +191,9 @@ $RECYCLE.BIN/ ############# *.py[co] +# Virtual environment +venv/ + # Packages *.egg *.egg-info diff --git a/Change Log.md b/Change Log.md index f7f18ee..21dea6d 100644 --- a/Change Log.md +++ b/Change Log.md @@ -1,5 +1,8 @@ # Change Log +## [0.4.3] - 2018-07-30 +- Add Azure MQTT and Text File interfaces + ## [0.4.2] - 2017-11-12 - Change bin folder to cmd diff --git a/cmd/floracmd b/cmd/floracmd index f439bbc..b1fba89 100755 --- a/cmd/floracmd +++ b/cmd/floracmd @@ -38,5 +38,5 @@ cli.add_command(device) if __name__ == '__main__': cli(obj={}) - + diff --git a/floranet/appserver/azure_iot_https.py b/floranet/appserver/azure_iot_https.py index 5165ff6..e80a424 100644 --- a/floranet/appserver/azure_iot_https.py +++ b/floranet/appserver/azure_iot_https.py @@ -109,6 +109,11 @@ def start(self, netserver): returnValue(True) yield + + def active(self): + """Return active status""" + + return self.started def stop(self): """Stop the application interface""" @@ -132,6 +137,9 @@ def netServerReceived(self, device, app, port, appdata): appdata (str): Application data """ + if not self.started: + returnValue(None) + # Map the device name the Azure IOT deviceId devid = device.appname if device.appname else device.name @@ -142,7 +150,7 @@ def netServerReceived(self, device, app, port, appdata): if prop is None: data = appdata else: - # Create the Azure message. If not mapped, transparently send appdata + # Create the Azure message. data = self._azureMessage(devid, prop, appdata) if data is None: log.debug("Application interface {name} could not create " diff --git a/floranet/appserver/azure_iot_mqtt.py b/floranet/appserver/azure_iot_mqtt.py new file mode 100644 index 0000000..d03c2d8 --- /dev/null +++ b/floranet/appserver/azure_iot_mqtt.py @@ -0,0 +1,253 @@ +from urlparse import parse_qs + +from twisted.internet import reactor, task, ssl +from twisted.internet.defer import inlineCallbacks, returnValue +from twisted.application.internet import ClientService, backoffPolicy +from twisted.internet.endpoints import clientFromString +from twisted.internet.protocol import Protocol + +from mqtt.client.factory import MQTTFactory +from flask_restful import fields, marshal + +from floranet.appserver.azure_iot import AzureIot +from floranet.models.application import Application +from floranet.models.appproperty import AppProperty +from floranet.models.device import Device +from floranet.log import log + +class AzureIotMqtt(AzureIot): + """LoRa application server interface to Microsoft Azure IoT platform, + using MQTT protocol. + + Attributes: + netserver (Netserver): The network server object + appinterface (AppInterface): The related AppInterface + iothost (str): Azure IOT host name + keyname (str): Azure IOT key name + keyvalue (str): Azure IOT key value + started (bool): State flag + """ + + TABLENAME = 'appif_azure_iot_mqtt' + HASMANY = [{'name': 'appinterfaces', 'class_name': 'AppInterface', 'as': 'interfaces'}] + + API_VERSION = '2016-11-14' + TOKEN_VALID_SECS = 300 + + def afterInit(self): + self.netserver = None + self.appinterface = None + self.started = False + self.polling = False + + @inlineCallbacks + def valid(self): + """Validate an AzureIotHttps object. + + Returns: + valid (bool), message(dict): (True, empty) on success, + (False, error message dict) otherwise. + """ + messages = {} + + valid = not any(messages) + returnValue((valid, messages)) + yield + + def marshal(self): + """Get REST API marshalled fields as an orderedDict + + Returns: + OrderedDict of fields defined by marshal_fields + """ + marshal_fields = { + 'type': fields.String(attribute='__class__.__name__'), + 'id': fields.Integer(attribute='appinterface.id'), + 'name': fields.String, + 'iothost': fields.String, + 'keyname': fields.String, + 'keyvalue': fields.String, + 'started': fields.Boolean, + } + return marshal(self, marshal_fields) + + @inlineCallbacks + def start(self, netserver): + """Start the application interface + + Args: + netserver (NetServer): The LoRa network server + + Returns True on success, False otherwise + """ + self.netserver = netserver + + # MQTT factory and endpoint + self.factory = MQTTFactory(profile=MQTTFactory.PUBLISHER | + MQTTFactory.SUBSCRIBER) + self.endpoint = clientFromString(reactor, + 'ssl:{}:8883'.format(self.iothost)) + + # Set the running flag + self.started = True + + returnValue(True) + yield + + @inlineCallbacks + def stop(self): + """Stop the application interface""" + + self.started = False + + @inlineCallbacks + def netServerReceived(self, device, app, port, appdata): + """Receive application data from the network server + + We publish outbound appdata to the Azure IOT hub host, and + receive inbound messages, via MQTT. + + Args: + device (Device): LoRa device object + app (Application): device's application + port (int): fport of the frame payload + appdata (str): Application data + """ + if not self.started: + returnValue(None) + + # Map the device name the Azure IOT deviceId + devid = device.appname if device.appname else device.name + + prop = yield AppProperty.find(where=['application_id = ? and port = ?', + app.id, port], limit=1) + + # If the property is not found, send the data as is. + if prop is None: + data = appdata + else: + # Create the Azure message. If not mapped, transparently send appdata + data = self._azureMessage(devid, prop, appdata) + if data is None: + log.debug("Application interface {name} could not create " + "message for property {prop}", name=self.name, prop=prop.name) + returnValue(None) + + resuri = '{}/devices/{}'.format("fluentiothub.azure-devices.net", devid) + username = 'fluentiothub.azure-devices.net/{}/api-version={}'.format( + devid, self.API_VERSION) + password = self._iotHubSasToken(resuri) + + service = MQTTService(self.endpoint, self.factory, devid, username, password) + messages = yield service.publishMessage(appdata) + + for m in messages: + self.netserver.netServerReceived(device.devaddr, m) + +class MQTTService(object): + """MQTT Service interface to Azure IoT hub. + + Attributes: + client: (ClientService): Twisted client service + connected (bool): Service connection flag + devid (str): Device identifer + username: (str): Azure IoT Hub MQTT username + password: (str): Azure IoT Hub MQTT password + messages (list): Received inbound messages + """ + + TIMEOUT = 10.0 + + def __init__(self, endpoint, factory, devid, username, password): + + self.client = ClientService(endpoint, factory) + self.connected = False + self.devid = devid + self.username = username + self.password = password + self.messages = [] + + @inlineCallbacks + def publishMessage(self, data): + """Publish the MQTT message. + + Any inbound messages are copied to the messages list attribute, + and returned to the caller. + + Args: + data (str): Application data to send + + Returns: + A list of received messages. + """ + # Start the service, and add a timeout to check the connection. + self.client.startService() + reactor.callLater(self.TIMEOUT, self.checkConnection) + + # Attempt to connect. If we tiemout and cancel and exception + # is thrown. + try: + yield self.client.whenConnected().addCallback( + self.azureConnect, data) + except Exception as e: + log.error("Azure MQTT service failed to connect to broker.") + + # Stop the service if sucessful, and finally return + # any inbound messages. + else: + yield self.client.stopService() + finally: + returnValue(self.messages) + + @inlineCallbacks + def checkConnection(self): + """Check if the connected flag is set. + + Stop the service if not. + """ + if not self.connected: + yield self.client.stopService() + + @inlineCallbacks + def azureConnect(self, protocol, data): + + self.connected = True + protocol.setWindowSize(1) + protocol.onPublish = self.onPublish + + pubtopic = 'devices/{}/messages/events/'.format(self.devid) + subtopic = 'devices/{}/messages/devicebound/#'.format(self.devid) + + try: + # Connect and subscribe + yield protocol.connect(self.devid, username=self.username, + password=self.password, cleanStart=False, keepalive=10) + yield protocol.subscribe(subtopic, 2) + except Exception as e: + log.error("Azure MQTT service could not connect to " + "Azure IOT Hub using username {name}", + name=self.username) + returnValue(None) + + # Publish the outbound message + yield protocol.publish(topic=pubtopic, qos=0, message=str(data)) + + def onPublish(self, topic, payload, qos, dup, retain, msgId): + """Receive messages from Azure IoT Hub + + IoT Hub delivers messages with the Topic Name + devices/{device_id}/messages/devicebound/ or + devices/{device_id}/messages/devicebound/{property_bag} + if there are any message properties. {property_bag} contains + url-encoded key/value pairs of message properties. + System property names have the prefix $, application properties + use the original property name with no prefix. + """ + message = '' + + # Split the component parameters of topic. Obtain the downstream message + # using the key name message. + params = parse_qs(topic) + if 'message' in params: + self.messages.append(params['message']) + diff --git a/floranet/appserver/file_text_store.py b/floranet/appserver/file_text_store.py new file mode 100644 index 0000000..2206499 --- /dev/null +++ b/floranet/appserver/file_text_store.py @@ -0,0 +1,93 @@ +import os + +from twisted.internet.defer import inlineCallbacks, returnValue +from flask_restful import fields, marshal + +from floranet.models.model import Model + +class FileTextStore(Model): + """File text storage application server interface + + This appserver interface saves data received to a file. + + Attributes: + name (str): Application interface name + file (str): File name + running (bool): Running flag + """ + + TABLENAME = 'appif_file_text_store' + HASMANY = [{'name': 'appinterfaces', 'class_name': 'AppInterface', 'as': 'interfaces'}] + + def afterInit(self): + self.started = False + self.appinterface = None + + @inlineCallbacks + def start(self, netserver): + """Start the application interface + + Args: + netserver (NetServer): The LoRa network server + + Returns True on success, False otherwise + """ + self.netserver = netserver + self.started = True + returnValue(True) + yield + + def stop(self): + """Stop the application interface""" + # Reflector does not require any shutdown + self.started = False + return + + @inlineCallbacks + def valid(self): + """Validate a FileTextStore object. + + Returns: + valid (bool), message(dict): (True, empty) on success, + (False, error message dict) otherwise. + """ + messages = {} + + # Check the file path + (path, name) = os.path.split(self.file) + if path and not os.path.isdir(path): + messages['file'] = "Directory {} does not exist".format(path) + + valid = not any(messages) + returnValue((valid, messages)) + yield + + def marshal(self): + """Get REST API marshalled fields as an orderedDict + + Returns: + OrderedDict of fields defined by marshal_fields + """ + marshal_fields = { + 'type': fields.String(attribute='__class__.__name__'), + 'id': fields.Integer(attribute='appinterface.id'), + 'name': fields.String, + 'file': fields.String, + 'started': fields.Boolean + } + return marshal(self, marshal_fields) + + def netServerReceived(self, device, app, port, appdata): + """Receive a application message from LoRa network server""" + + # Write data to our file, append and create if it doesn't exist. + fp = open(self.file, 'a+') + fp.write(appdata) + fp.close() + + + def datagramReceived(self, data, (host, port)): + """Receive inbound application server data""" + pass + + \ No newline at end of file diff --git a/floranet/commands/interface.py b/floranet/commands/interface.py index 47c8804..f332008 100644 --- a/floranet/commands/interface.py +++ b/floranet/commands/interface.py @@ -35,6 +35,10 @@ def show(ctx, id): for i,a in sorted(data.iteritems()): if a['type'] == 'AzureIotHttps': a['type'] = 'Azure HTTPS' + elif a['type'] == 'AzureIotMqtt': + a['type'] = 'Azure MQTT' + elif a['type'] == 'FileTextStore': + a['type'] = 'Text File' click.echo('{:3}'.format(a['id']) + ' ' + \ '{:23}'.format(a['name']) + ' ' + \ '{:14}'.format(a['type'])) @@ -45,10 +49,16 @@ def show(ctx, id): indent = ' ' * 10 started = 'Started' if i['started'] else 'Stopped' - if i['type'] == 'Reflector': + if i['type'] == 'Reflector': click.echo('{}name: {}'.format(indent, i['name'])) click.echo('{}type: {}'.format(indent, i['type'])) click.echo('{}status: {}'.format(indent, started)) + + elif i['type'] == 'FileTextStore': + click.echo('{}name: {}'.format(indent, i['name'])) + click.echo('{}type: {}'.format(indent, i['type'])) + click.echo('{}status: {}'.format(indent, started)) + click.echo('{}file: {}'.format(indent, i['file'])) elif i['type'] == 'AzureIotHttps': protocol = 'HTTPS' @@ -60,6 +70,13 @@ def show(ctx, id): format(indent, i['poll_interval'])) click.echo('{}status: {}'.format(indent, started)) + elif i['type'] == 'AzureIotMqtt': + protocol = 'MQTT' + click.echo('{}name: {}'.format(indent, i['name'])) + click.echo('{}protocol: {}'.format(indent, protocol)) + click.echo('{}key name: {}'.format(indent, i['keyname'])) + click.echo('{}key value: {}'.format(indent, i['keyvalue'])) + click.echo('{}status: {}'.format(indent, started)) return @interface.command(context_settings=dict( @@ -78,7 +95,7 @@ def add(ctx, type): args = dict(item.split('=', 1) for item in ctx.args) iftype = type.lower() - types = {'reflector', 'azure'} + types = {'reflector', 'azure', 'filetext'} # Check for required args if not iftype in types: @@ -86,11 +103,16 @@ def add(ctx, type): return required = {'reflector': ['name'], + 'filetext': ['name', 'file'], 'azure': ['protocol', 'name' , 'iothost', 'keyname', - 'keyvalue', 'pollinterval'] + 'keyvalue'] } missing = [item for item in required[iftype] if item not in args.keys()] + + if type == 'azure' and 'protocol' == 'https' and not 'pollinterval' in args.keys(): + missing.append('pollinterval') + if missing: if len(missing) == 1: click.echo("Missing argument " + missing[0]) @@ -114,7 +136,7 @@ def add(ctx, type): @click.argument('id') @click.pass_context def set(ctx, id): - """modify an interface. + """Modify an interface. Args: ctx (Context): Click context diff --git a/floranet/data/alembic/versions/09a18d3d3b1e_create_appif_azureiotmqtt_table.py b/floranet/data/alembic/versions/09a18d3d3b1e_create_appif_azureiotmqtt_table.py new file mode 100644 index 0000000..a4bb209 --- /dev/null +++ b/floranet/data/alembic/versions/09a18d3d3b1e_create_appif_azureiotmqtt_table.py @@ -0,0 +1,32 @@ +"""create azure iot mqtt table + +Revision ID: 09a18d3d3b1e +Revises: d56db793263d +Create Date: 2017-12-05 13:30:03.219377 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '09a18d3d3b1e' +down_revision = 'd56db793263d' +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + 'appif_azure_iot_mqtt', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.String, nullable=False, unique=True), + sa.Column('iothost', sa.String, nullable=False), + sa.Column('keyname', sa.String, nullable=False), + sa.Column('keyvalue', sa.String, nullable=False), + sa.Column('created', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated', sa.DateTime(timezone=True), nullable=False), + ) + +def downgrade(): + op.drop_table('appif_azure_iot_mqtt') + diff --git a/floranet/data/alembic/versions/d5ed30f62f76_create_appif_file_text_store.py b/floranet/data/alembic/versions/d5ed30f62f76_create_appif_file_text_store.py new file mode 100644 index 0000000..ffbc4e0 --- /dev/null +++ b/floranet/data/alembic/versions/d5ed30f62f76_create_appif_file_text_store.py @@ -0,0 +1,29 @@ +"""create appif file text store + +Revision ID: d5ed30f62f76 +Revises: 09a18d3d3b1e +Create Date: 2018-07-30 18:47:12.417385 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd5ed30f62f76' +down_revision = '09a18d3d3b1e' +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + 'appif_file_text_store', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.String, nullable=False, unique=True), + sa.Column('file', sa.String, nullable=False, unique=True), + sa.Column('created', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated', sa.DateTime(timezone=True), nullable=False), + ) + +def downgrade(): + op.drop_table('appif_file_text_store') diff --git a/floranet/database.py b/floranet/database.py index ebb194b..f0b7069 100644 --- a/floranet/database.py +++ b/floranet/database.py @@ -11,6 +11,8 @@ from floranet.models.appproperty import AppProperty from floranet.appserver.reflector import Reflector from floranet.appserver.azure_iot_https import AzureIotHttps +from floranet.appserver.azure_iot_mqtt import AzureIotMqtt +from floranet.appserver.file_text_store import FileTextStore from floranet.log import log class Option(object): @@ -81,7 +83,8 @@ def register(self): # Application, AppInterface and AppProperty Registry.register(Application, AppInterface, AppProperty) # AppInterface and the concrete classes - Registry.register(Reflector, AzureIotHttps, AppInterface) + Registry.register(Reflector, FileTextStore, AzureIotHttps, AzureIotMqtt, + AppInterface) def _getOption(self, section, option, obj): """Parse options for the section diff --git a/floranet/imanager.py b/floranet/imanager.py index 14cb7c6..784f6f9 100644 --- a/floranet/imanager.py +++ b/floranet/imanager.py @@ -1,6 +1,7 @@ from twisted.internet.defer import inlineCallbacks, returnValue from floranet.models.appinterface import AppInterface +from floranet.models.application import Application from floranet.log import log class InterfaceManager(object): @@ -44,7 +45,11 @@ def start(self, netserver): "id {id}", id=interface.appinterface.id) def getInterface(self, appinterface_id): - """Retrieve an interface by application interface id""" + """Retrieve an interface by application interface id + + Args: + appinterface_id (int): Application interface id + """ interface = next((i for i in self.interfaces if i.appinterface.id == int(appinterface_id)), None) @@ -57,6 +62,22 @@ def getAllInterfaces(self): return None return self.interfaces + @inlineCallbacks + def checkInterface(self, appinterface_id): + """Check the referenced interface is required to be active. + + Args: + appinterface_id (int): Application interface id + """ + interface = self.getInterface(appinterface_id) + if interface is None: + returnValue(None) + + active = yield interface.apps() + if active is None: + interface.stop() + + @inlineCallbacks def createInterface(self, interface): """Add an interface to the interface list" @@ -91,7 +112,6 @@ def updateInterface(self, interface): """Update an existing interface Args: - appinterface (AppInterface): The Appinterface object interface: The concrete application interface """ diff --git a/floranet/models/appinterface.py b/floranet/models/appinterface.py index acafe90..f0e677f 100644 --- a/floranet/models/appinterface.py +++ b/floranet/models/appinterface.py @@ -19,5 +19,11 @@ class AppInterface(Model): TABLENAME = 'appinterfaces' BELONGSTO = [{'name': 'interfaces', 'polymorphic': True}] - + @inlineCallbacks + def apps(self): + """Flags whether this interface has any associated Applications + + """ + apps = yield Application.find(where=['appinterface_id = ?', self.appinterface.id]) + returnValue(apps) diff --git a/floranet/test/integration/test_azure_https.py b/floranet/test/integration/test_azure_https.py index 8fdb59c..49cc06b 100644 --- a/floranet/test/integration/test_azure_https.py +++ b/floranet/test/integration/test_azure_https.py @@ -60,8 +60,8 @@ def test_AzureIotHttps_outbound(self): appdata = struct.pack(' interface add azure protocol=mqtt name=AzureTest +iothost=test-floranet.azure-devices.net keyname=iothubowner +keyvalue=CgqCQ1nMMk3TYDU6vYx2wgipQfX0Av7STc8 +""" +AzureIoTHubName = 'AzureMqttTest' + +""" +Azure IoT Hub Device Explorer should be used to verify outbound +(Device to Cloud) messages are received, and to send inbound +(Cloud to Device) test messages. +""" + +class AzureIotMQTTTest(unittest.TestCase): + """Test send and receive messages to Azure IoT Hub + + """ + + @inlineCallbacks + def setUp(self): + + # Bootstrap the database + fpath = os.path.realpath(__file__) + config = os.path.dirname(fpath) + '/database.cfg' + log.start(True, '', True) + + db = Database() + db.parseConfig(config) + db.start() + db.register() + + self.device = yield Device.find(where=['appname = ?', + 'azuredevice02'], limit=1) + self.app = yield Application.find(where=['appeui = ?', + self.device.appeui], limit=1) + + @inlineCallbacks + def test_AzureIotMqtt(self): + """Test sending & receiving sample data to/from an + Azure IoT Hub instance""" + + interface = yield AzureIotMqtt.find(where=['name = ?', + AzureIoTHubName], limit=1) + + port = 11 + appdata = "{ Temperature: 42.3456 }" + + yield interface.start(None) + yield interface.netServerReceived(self.device, self.app, port, appdata) + + + + + diff --git a/floranet/test/integration/test_file_text_store.py b/floranet/test/integration/test_file_text_store.py new file mode 100644 index 0000000..815f433 --- /dev/null +++ b/floranet/test/integration/test_file_text_store.py @@ -0,0 +1,58 @@ +import os +import struct + +from twisted.trial import unittest + +from twisted.internet.defer import inlineCallbacks +from twistar.registry import Registry + +from floranet.models.device import Device +from floranet.models.application import Application +from floranet.appserver.file_text_store import FileTextStore +from floranet.database import Database +from floranet.log import log +""" +Text file store application interface to use. Configure +this interface with the name and file +floranet> interface add filetext name=Testfile file=/tmp/test.txt +""" +class FileTextStoreTest(unittest.TestCase): + """Test sending message to a text file + + """ + + @inlineCallbacks + def setUp(self): + + # Bootstrap the database + fpath = os.path.realpath(__file__) + config = os.path.dirname(fpath) + '/database.cfg' + log.start(True, '', True) + + db = Database() + db.parseConfig(config) + db.start() + db.register() + + self.device = yield Device.find(where=['name = ?', + 'abp_device'], limit=1) + self.app = yield Application.find(where=['appeui = ?', + self.device.appeui], limit=1) + + @inlineCallbacks + def test_FileTextStore(self): + """Test sending data to a text file.""" + + interface = yield FileTextStore.find(where=['name = ?', + 'Test'], limit=1) + + port = 15 + appdata = "{ Temperature: 42.3456 }" + + yield interface.start(None) + yield interface.netServerReceived(self.device, self.app, port, appdata) + + + + + diff --git a/floranet/test/unit/web/test_restappinterface.py b/floranet/test/unit/web/test_restappinterface.py index fa8daff..b14c3ae 100644 --- a/floranet/test/unit/web/test_restappinterface.py +++ b/floranet/test/unit/web/test_restappinterface.py @@ -154,7 +154,7 @@ class RestAppInterfacesTest(unittest.TestCase): @inlineCallbacks def setUp(self): - # Twistar requirem + # Twistar setup Registry.getConfig = MagicMock(return_value=None) db = Database() db.register() @@ -230,15 +230,6 @@ def test_post(self): args['name'] = None yield self.assertFailure(resource.post(), e.BadRequest) args['name'] = interface.name - - # Interface exists - raises 400 BadRequest - with patch.object(reqparse.RequestParser, 'parse_args', - MagicMock(return_value=args)), \ - patch.object(AzureIotHttps, 'exists', - MagicMock(return_value=True)): - resource = RestAppInterfaces(restapi=self.restapi, - server=self.server) - yield self.assertFailure(resource.post(), e.BadRequest) # Invalid interface - raises 400 BadRequest with patch.object(reqparse.RequestParser, 'parse_args', diff --git a/floranet/web/rest/appinterface.py b/floranet/web/rest/appinterface.py index 2878c5e..668c11c 100644 --- a/floranet/web/rest/appinterface.py +++ b/floranet/web/rest/appinterface.py @@ -7,6 +7,8 @@ from floranet.models.appinterface import AppInterface from floranet.appserver.azure_iot_https import AzureIotHttps +from floranet.appserver.azure_iot_mqtt import AzureIotMqtt +from floranet.appserver.file_text_store import FileTextStore from floranet.appserver.reflector import Reflector from floranet.imanager import interfaceManager from floranet.util import euiString, intHexString @@ -37,6 +39,7 @@ def __init__(self, **kwargs): self.parser = reqparse.RequestParser(bundle_errors=True) self.parser.add_argument('type', type=str) self.parser.add_argument('name', type=str) + self.parser.add_argument('file', type=str) self.parser.add_argument('protocol', type=str) self.parser.add_argument('iothost', type=str) self.parser.add_argument('keyname', type=str) @@ -171,7 +174,6 @@ def get(self): except TimeoutError: log.error("REST API timeout retrieving application interfaces") - @login_required @wait_for(timeout=TIMEOUT) @inlineCallbacks @@ -188,33 +190,45 @@ def post(self): protocol = self.args['protocol'] iothost = self.args['iothost'] keyname = self.args['keyname'] - keyvalue = self.args['keyvalue'] + keyvalue = self.args['keyvalue'] poll_interval = self.args['pollinterval'] # Check for required args required = {'type', 'name', 'protocol', 'iothost', - 'keyname', 'keyvalue', 'pollinterval'} + 'keyname', 'keyvalue'} for r in required: if self.args[r] is None: message[r] = "Missing the {} parameter.".format(r) + if protocol == 'https' and poll_interval is None: + message['poll_interval'] = "Missing the poll_interval parameter." if message: abort(400, message=message) - if protocol != 'https': + # Check protocol type + if not protocol in {'https', 'mqtt'}: message = "Unknown protocol type {}.".format(protocol) abort(400, message=message) - - # Check this interface does not currently exist - exists = yield AzureIotHttps.exists(where=['name = ?', name]) - if exists: - message = {'error': "Azure Https Interface {} currently exists" - .format(name)} - abort(400, message=message) - + # Create the interface - interface = AzureIotHttps(name=name, iothost=iothost, keyname=keyname, - keyvalue=keyvalue, poll_interval=poll_interval) + if protocol == 'https': + interface = AzureIotHttps(name=name, iothost=iothost, keyname=keyname, + keyvalue=keyvalue, poll_interval=poll_interval) + elif protocol == 'mqtt': + interface = AzureIotMqtt(name=name, iothost=iothost, keyname=keyname, + keyvalue=keyvalue) + elif klass == 'filetext': + file = self.args['file'] + required = {'type', 'name', 'file'} + for r in required: + if self.args[r] is None: + message[r] = "Missing the {} parameter.".format(r) + if message: + abort(400, message=message) + + # Create the interface + interface = FileTextStore(name=name, file=file) + elif klass == 'reflector': # Required args diff --git a/floranet/web/rest/application.py b/floranet/web/rest/application.py index d636940..6c7d274 100644 --- a/floranet/web/rest/application.py +++ b/floranet/web/rest/application.py @@ -6,6 +6,7 @@ from crochet import wait_for, TimeoutError from floranet.models.application import Application +from floranet.imanager import interfaceManager from floranet.models.device import Device from floranet.util import euiString, intHexString from floranet.log import log @@ -54,7 +55,7 @@ def __init__(self, **kwargs): self.parser.add_argument('appnonce', type=int) self.parser.add_argument('appkey', type=int) self.parser.add_argument('fport', type=int) - self.parser.add_argument('appinterface_id', type=int) + self.parser.add_argument('interface', type=int) self.args = self.parser.parse_args() @inlineCallbacks @@ -120,6 +121,9 @@ def put(self, appeui): abort(404, message={'error': "Application {} doesn't exist." .format(euiString(appeui))}) + self.args['appinterface_id'] = self.args.pop('interface') + current_appif = app.appinterface_id + kwargs = {} for a,v in self.args.items(): if v is not None and v != getattr(app, a): @@ -132,6 +136,8 @@ def put(self, appeui): # Update the model if kwargs: app.update(**kwargs) + if current_appif != app.appinterface_id: + yield interfaceManager.checkInterface(current_appif) returnValue(({}, 200)) except TimeoutError: diff --git a/setup.py b/setup.py index f4c261b..61ad7ec 100644 --- a/setup.py +++ b/setup.py @@ -9,8 +9,11 @@ author_email = 'frank.edwards@fluentnetworks.com.au', url = 'https://github.com/fluentnetworks/floranet', packages = find_packages(exclude=["*.test", "*.test.*"]), - install_requires = ['twisted>=16.1.1,<16.7.0', 'psycopg2>=1.6', - 'twistar>=1.6', 'alembic>=0.8.8', + install_requires = ['twisted==18.4.0', 'psycopg2>=1.6', + 'pyOpenSSL>=18.0.0', 'pyasn1>=0.4.3', + 'service-identity>=17.0.0', + 'twisted-mqtt>=0.3.6', + 'twistar>=1.6', 'alembic>=0.8.8', 'py2-ipaddress>=3.4.1', 'pycrypto>=2.6.1', 'CryptoPlus==1.0', 'requests>=2.13.0', 'flask>=0.12',