├── .gitignore ├── Change Log.md ├── LICENSE ├── README.md ├── cmd ├── floracmd └── floranet ├── config └── database.cfg ├── floranet ├── __init__.py ├── appserver │ ├── __init__.py │ ├── azure_iot.py │ ├── azure_iot_https.py │ ├── azure_iot_mqtt.py │ ├── file_text_store.py │ └── reflector.py ├── commands │ ├── __init__.py │ ├── app.py │ ├── device.py │ ├── gateway.py │ ├── interface.py │ ├── property.py │ └── system.py ├── data │ ├── alembic.ini │ ├── alembic │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── 03fabc9f542b_create_gateways_table_add_devnonce.py │ │ │ ├── 09a18d3d3b1e_create_appif_azureiotmqtt_table.py │ │ │ ├── 12d5dcd9a231_add_device_adr_flag.py │ │ │ ├── 282e6b269222_create_device_name_lat_long_enabled_and_otaa_.py │ │ │ ├── 56e7e493cad7_add_device_snr_array_column.py │ │ │ ├── 5f0ed1bab7fa_create_config_table.py │ │ │ ├── 66bc8df33d36create_application_interfaces_relation.py │ │ │ ├── 996580ba7f6c_add_device_appname_column.py │ │ │ ├── 99f8aa50ac47_otaa_devices_to_be_pre_provisioned_in_.py │ │ │ ├── ad38a9fad16b_add_device_adr_creation_and_update_columns.py │ │ │ ├── b664cccf21a2_device_deveui_unique_constraint.py │ │ │ ├── bdf0f3bcffc7_create_appif_reflector_azureiothttps.py │ │ │ ├── d56db793263d_create_app_properties_table.py │ │ │ ├── d5ed30f62f76_create_appif_file_text_store.py │ │ │ ├── e7ff8a1b22fd_create_device_table.py │ │ │ └── f966d7f314d5_create_application_table.py │ └── seed │ │ ├── applications.csv │ │ ├── applications.py │ │ ├── devices.csv │ │ ├── devices.py │ │ ├── gateways.csv │ │ ├── gateways.py │ │ └── seeder ├── database.py ├── error.py ├── imanager.py ├── log.py ├── lora │ ├── __init__.py │ ├── bands.py │ ├── crypto.py │ ├── mac.py │ └── wan.py ├── models │ ├── __init__.py │ ├── appinterface.py │ ├── application.py │ ├── appproperty.py │ ├── config.py │ ├── device.py │ ├── gateway.py │ └── model.py ├── multitech │ ├── conduit │ │ ├── AU_global_conf.json │ │ └── US_global_conf.json │ └── mdot │ │ ├── mDot-demo-abp-au915.bin │ │ ├── mDot-demo-abp-us915.bin │ │ ├── mDot-demo-otaa-au915.bin │ │ └── mDot-demo-otaa-us915.bin ├── netserver.py ├── test │ ├── __init__.py │ ├── integration │ │ ├── __init__.py │ │ ├── database.cfg │ │ ├── test_azure_https.py │ │ ├── test_azure_mqtt.py │ │ └── test_file_text_store.py │ ├── test.sh │ └── unit │ │ ├── __init__.py │ │ ├── default.cfg │ │ ├── floranet │ │ ├── __init__.py │ │ ├── test_database.py │ │ ├── test_device.py │ │ ├── test_mac.py │ │ ├── test_netserver.py │ │ ├── test_util.py │ │ └── test_wan.py │ │ ├── mock_dbobject.py │ │ ├── mock_model.py │ │ ├── mock_reactor.py │ │ ├── mock_relationship.py │ │ └── web │ │ ├── __init__.py │ │ ├── test_restappinterface.py │ │ ├── test_restapplication.py │ │ ├── test_restappproperty.py │ │ ├── test_restdevice.py │ │ ├── test_restgateway.py │ │ └── test_webserver.py ├── util.py └── web │ ├── __init__.py │ ├── rest │ ├── __init__.py │ ├── appinterface.py │ ├── application.py │ ├── appproperty.py │ ├── device.py │ ├── gateway.py │ ├── restapi.py │ └── system.py │ └── webserver.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | tmp/ 9 | *.tmp 10 | *.bak 11 | *.swp 12 | *~.nib 13 | local.properties 14 | .classpath 15 | .settings/ 16 | .loadpath 17 | 18 | # External tool builders 19 | .externalToolBuilders/ 20 | 21 | # Locally stored "Eclipse launch configurations" 22 | *.launch 23 | 24 | # CDT-specific 25 | .cproject 26 | 27 | # PDT-specific 28 | .buildpath 29 | 30 | 31 | ################# 32 | ## Visual Studio 33 | ################# 34 | 35 | ## Ignore Visual Studio temporary files, build results, and 36 | ## files generated by popular Visual Studio add-ons. 37 | 38 | # User-specific files 39 | *.suo 40 | *.user 41 | *.sln.docstates 42 | 43 | # Build results 44 | 45 | [Dd]ebug/ 46 | [Rr]elease/ 47 | x64/ 48 | build/ 49 | [Bb]in/ 50 | [Oo]bj/ 51 | 52 | # MSTest test Results 53 | [Tt]est[Rr]esult*/ 54 | [Bb]uild[Ll]og.* 55 | 56 | *_i.c 57 | *_p.c 58 | *.ilk 59 | *.meta 60 | *.obj 61 | *.pch 62 | *.pdb 63 | *.pgc 64 | *.pgd 65 | *.rsp 66 | *.sbr 67 | *.tlb 68 | *.tli 69 | *.tlh 70 | *.tmp 71 | *.tmp_proj 72 | *.log 73 | *.vspscc 74 | *.vssscc 75 | .builds 76 | *.pidb 77 | *.log 78 | *.scc 79 | 80 | # Visual C++ cache files 81 | ipch/ 82 | *.aps 83 | *.ncb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | 88 | # Visual Studio profiler 89 | *.psess 90 | *.vsp 91 | *.vspx 92 | 93 | # Guidance Automation Toolkit 94 | *.gpState 95 | 96 | # ReSharper is a .NET coding add-in 97 | _ReSharper*/ 98 | *.[Rr]e[Ss]harper 99 | 100 | # TeamCity is a build add-in 101 | _TeamCity* 102 | 103 | # DotCover is a Code Coverage Tool 104 | *.dotCover 105 | 106 | # NCrunch 107 | *.ncrunch* 108 | .*crunch*.local.xml 109 | 110 | # Installshield output folder 111 | [Ee]xpress/ 112 | 113 | # DocProject is a documentation generator add-in 114 | DocProject/buildhelp/ 115 | DocProject/Help/*.HxT 116 | DocProject/Help/*.HxC 117 | DocProject/Help/*.hhc 118 | DocProject/Help/*.hhk 119 | DocProject/Help/*.hhp 120 | DocProject/Help/Html2 121 | DocProject/Help/html 122 | 123 | # Click-Once directory 124 | publish/ 125 | 126 | # Publish Web Output 127 | *.Publish.xml 128 | *.pubxml 129 | 130 | # NuGet Packages Directory 131 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 132 | #packages/ 133 | 134 | # Windows Azure Build Output 135 | csx 136 | *.build.csdef 137 | 138 | # Windows Store app package directory 139 | AppPackages/ 140 | 141 | # Others 142 | sql/ 143 | *.Cache 144 | ClientBin/ 145 | [Ss]tyle[Cc]op.* 146 | ~$* 147 | *~ 148 | *.dbmdl 149 | *.[Pp]ublish.xml 150 | *.pfx 151 | *.publishsettings 152 | 153 | # RIA/Silverlight projects 154 | Generated_Code/ 155 | 156 | # Backup & report files from converting an old project file to a newer 157 | # Visual Studio version. Backup files are not needed, because we have git ;-) 158 | _UpgradeReport_Files/ 159 | Backup*/ 160 | UpgradeLog*.XML 161 | UpgradeLog*.htm 162 | 163 | # SQL Server files 164 | App_Data/*.mdf 165 | App_Data/*.ldf 166 | 167 | ############# 168 | ## Windows detritus 169 | ############# 170 | 171 | # Windows image file caches 172 | Thumbs.db 173 | ehthumbs.db 174 | 175 | # Folder config file 176 | Desktop.ini 177 | 178 | # Recycle Bin used on file shares 179 | $RECYCLE.BIN/ 180 | 181 | # Mac crap 182 | .DS_Store 183 | 184 | ############# 185 | ## Komodo 186 | ############# 187 | *.komodoproject 188 | 189 | ############# 190 | ## Python 191 | ############# 192 | *.py[co] 193 | 194 | # Virtual environment 195 | venv/ 196 | 197 | # Packages 198 | *.egg 199 | *.egg-info 200 | dist/ 201 | build/ 202 | eggs/ 203 | parts/ 204 | var/ 205 | sdist/ 206 | develop-eggs/ 207 | .installed.cfg 208 | 209 | # Installer logs 210 | pip-log.txt 211 | 212 | # Unit test / coverage reports 213 | .coverage 214 | .tox 215 | _trial* 216 | test.log 217 | 218 | #Translations 219 | *.mo 220 | 221 | #Mr Developer 222 | .mr.developer.cfg 223 | 224 | ############# 225 | ## PyCharm 226 | ############# 227 | .idea 228 | -------------------------------------------------------------------------------- /Change Log.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.4.8] - 2018-08-17 4 | ### Fixed 5 | - Bug fix: Detect and log MAC message decode errors when processing PUSH_DATA messages. 6 | 7 | ## [0.4.7] - 2018-08-06 8 | ### Fixed 9 | - Bug fix: When decrypting messages, we were adding an unnecessary padding byte when dealing with frame payloads that are an integer multiple of 16. 10 | 11 | ## [0.4.6] - 2018-08-02 12 | ### Fixed 13 | - Bug fix for device frame count check. 14 | 15 | ## [0.4.5] - 2018-08-01 16 | ### Fixed 17 | - Fix exception being thrown on change of application interface 18 | 19 | ### Added 20 | - Add version info message on startup 21 | 22 | ## [0.4.4] - 2018-07-31 23 | ### Changed 24 | - Enable relaxed frame count to accept fcntup as 0 or 1 25 | 26 | ## [0.4.3] - 2018-07-30 27 | ### Added 28 | - Add Azure MQTT and Text File interfaces 29 | 30 | ## [0.4.2] - 2017-11-12 31 | ### Changed 32 | - Change bin folder to cmd 33 | 34 | ## [0.4.1] - 2017-11-12 35 | ### Changed 36 | - Alter .gitignore 37 | 38 | ## [0.4.0] - 2017-11-11 39 | ### Added 40 | - Major upgrade to support cloud-based application interfaces - Azure IoT Hub 41 | - Created application properties, enabling efficient transfer of upstream application message data 42 | - Added the stand-alone command line configuration tool: floracmd 43 | - REST API 44 | - LoRaWAN Overview, Setup Guide, and Reference Network documentation added in the Wiki. 45 | 46 | ## [0.3.3] - 2017-01-03 47 | ### Changed 48 | - Update README 49 | - Revert default.cfg 50 | 51 | ## [0.3.2] - 2017-01-31 52 | ### Added 53 | - Initial support for EU868 MHz band 54 | 55 | ### Fixed 56 | - Bug fix: If adrenable is set to false, each call to NetServer \_processADRRequests throws a TypeError exception, as no argument is passed to ReturnValue(). NetServer start() method will now not start the LoopingCall if adrenable is false. 57 | 58 | ## [0.3.1] - 2017-01-03 59 | ### Changed 60 | - Update README 61 | 62 | ## [0.3.0] - 2017-01-02 63 | ### Added 64 | - Support for adaptive data rate (ADR) control and ADR MAC commands 65 | - Support for encoding & decoding MAC commands sent in the fopts field in LoRa MAC messages 66 | - Support for MAC command queuing, allows commands to be sent in the downlink message window 67 | - Added database connection test on server startup 68 | 69 | ## [0.2.3] - 2016-12-05 70 | ### Fixed 71 | - Bug fix: we were not resetting device fcntup and fcntdown to zero on a OTA re-join. This caused OTA devices to fail the device frame count check. 72 | 73 | ## [0.2.2] - 2016-12-05 74 | ### Fixed 75 | - Bug fix for device frame count increment. 76 | 77 | ## [0.2.1] - 2016-12-02 78 | ### Changed 79 | - Tidy up test files. 80 | - Minor edits to Readme 81 | 82 | ## [0.2.0] - 2016-12-01 83 | ### Added 84 | - Support for persistent device state using Postgres database. 85 | - Relaxed frame count option to cater for device 86 | resets where the first received fcntup after a reset is 1. 87 | - Change log. 88 | 89 | ### Changed 90 | - Band configuration loading. 91 | - Server start method to support twistar. 92 | - Tag each device with a gateway address. 93 | - Unit test mocks. 94 | 95 | ## [0.1.0] - 2016-09-23 96 | ### Added 97 | - Initial verison. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Fluent Networks Pty Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FloraNet 2 | FloraNet is a LoRaWAN™ Network Server. 3 | 4 | In a LoRaWAN network, the Network Server (NS) is a central element that communicates to end-devices via gateways, and provides upstream interfaces to IoT application servers. 5 | 6 | For much more information on Floranet, including setup, configuration and examples, see the [Floranet Wiki](https://github.com/Fluent-networks/floranet/wiki). 7 | 8 | ### Features 9 | * Supports Class A and Class C end-devices. 10 | * Supports US 902-928 MHz and AU 915-928 MHz ISM bands. 11 | * Support for over the air (OTA) activation and activation by personalisation (ABP) join procedures. 12 | * Support for adaptive data rate (ADR) control. 13 | * De-duplication of messages delivered from multiple gateways. 14 | * Application interfaces using Azure IoT Hub (HTTPS and MQTT), and text file storage. 15 | 16 | ### Limitations 17 | * No support for CN779-787 or EU433 frequency bands. 18 | * No support for Class B end-devices. 19 | * Support for EU863-870 is at alpha stage. 20 | 21 | -------------------------------------------------------------------------------- /cmd/floracmd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | import click 6 | import click_shell 7 | 8 | from floranet.commands.system import system 9 | from floranet.commands.app import app 10 | from floranet.commands.interface import interface 11 | from floranet.commands.property import property 12 | from floranet.commands.gateway import gateway 13 | from floranet.commands.device import device 14 | 15 | @click.option('--server', '-s', 16 | default='localhost:8000', 17 | envvar='FLORANET_SERVER', 18 | help='Floranet server [host:port].') 19 | @click.option('--token', '-t', 20 | default='', 21 | envvar='FLORANET_TOKEN', 22 | help='Floranet API token.') 23 | 24 | @click_shell.shell(prompt='floranet> ') 25 | @click.pass_context 26 | 27 | def cli(ctx, server, token): 28 | ctx.obj['server'] = server 29 | ctx.obj['token'] = token 30 | click.echo('Using floranet server {}'.format(server)) 31 | 32 | cli.add_command(system) 33 | cli.add_command(app) 34 | cli.add_command(interface) 35 | cli.add_command(property) 36 | cli.add_command(gateway) 37 | cli.add_command(device) 38 | 39 | if __name__ == '__main__': 40 | cli(obj={}) 41 | 42 | 43 | -------------------------------------------------------------------------------- /cmd/floranet: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import os 5 | import sys 6 | import pkg_resources 7 | 8 | from twisted.internet import reactor 9 | from twisted.internet.defer import inlineCallbacks, returnValue 10 | 11 | from floranet.database import Database 12 | from floranet.models.config import Config 13 | from floranet.netserver import NetServer 14 | from floranet.log import log 15 | 16 | def parseCommandLine(): 17 | """Parse command line arguments""" 18 | 19 | parser = argparse.ArgumentParser(description='FloraNet network server.') 20 | parser.add_argument('-c', dest='config', action='store', 21 | default='database.cfg', metavar='config', 22 | help='database configuration file') 23 | parser.add_argument('-d', dest='debug', action='store_true', 24 | help='run in debug mode') 25 | parser.add_argument('-f', dest='foreground', action='store_true', 26 | help='run in foreground, log to console') 27 | parser.add_argument('-l', dest='logfile', action='store', 28 | default='/tmp/floranet.log', metavar='logfile', 29 | help='log file (default: /tmp/floranet.log)') 30 | return parser.parse_args() 31 | 32 | @inlineCallbacks 33 | def startup(): 34 | """Read the system config and start the server""" 35 | 36 | # Read the server configuration. If no configuration exists, 37 | # load the factory defaults. 38 | config = yield Config.find(limit=1) 39 | if config is None: 40 | log.info("No system configuration found. Loading factory defaults") 41 | config = yield Config.loadFactoryDefaults() 42 | if config is None: 43 | log.error("Error reading the server configuration: shutdown.") 44 | reactor.stop() 45 | 46 | # Create the netserver and start 47 | server = NetServer(config) 48 | server.start() 49 | 50 | if __name__ == '__main__': 51 | """ __main__ """ 52 | 53 | # Parse command line arguments 54 | options = parseCommandLine() 55 | 56 | # Start the log 57 | if not log.start(options.foreground, options.logfile, options.debug): 58 | exit(1) 59 | 60 | version = pkg_resources.require('Floranet')[0].version 61 | log.info("Floranet version {version}", version=version) 62 | log.info("Starting up") 63 | 64 | # Load the database configuration 65 | db = Database() 66 | log.info("Reading database configuration from {config}", 67 | config=options.config) 68 | if not db.parseConfig(options.config): 69 | exit(1) 70 | 71 | # Test the database connection 72 | if not db.test(): 73 | log.error("Error connecting to database {database} on " 74 | "host '{host}', user '{user}'. Check the database " 75 | "and user credentials.", 76 | database=db.database, host=db.host, user=db.user) 77 | log.error("Exiting") 78 | exit(1) 79 | 80 | # Start the database 81 | db.start() 82 | 83 | # Register ORM models 84 | db.register() 85 | 86 | # Run the reactor and call startup 87 | reactor.callWhenRunning(startup) 88 | reactor.run() 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /config/database.cfg: -------------------------------------------------------------------------------- 1 | # Database connection parameters 2 | [database] 3 | host = 127.0.0.1 4 | user = floranet 5 | password = florapass 6 | database = floranet 7 | 8 | -------------------------------------------------------------------------------- /floranet/__init__.py: -------------------------------------------------------------------------------- 1 | #TODO: add some useful description 2 | """Placeholder 3 | """ 4 | pass 5 | 6 | 7 | -------------------------------------------------------------------------------- /floranet/appserver/__init__.py: -------------------------------------------------------------------------------- 1 | #TODO: add some useful description 2 | """Placeholder 3 | """ 4 | pass 5 | 6 | 7 | -------------------------------------------------------------------------------- /floranet/appserver/azure_iot.py: -------------------------------------------------------------------------------- 1 | import time 2 | import hmac 3 | import hashlib 4 | import base64 5 | import urllib 6 | 7 | from floranet.models.model import Model 8 | 9 | class AzureIot(Model): 10 | """Base class application server interface to Microsoft Azure IoT 11 | """ 12 | 13 | def _iotHubSasToken(self, uri): 14 | """Create the Azure IOT Hub SAS token 15 | 16 | Args: 17 | uri (str): Resource URI 18 | 19 | Returns: 20 | Token string 21 | """ 22 | expiry = str(int((time.time() + self.TOKEN_VALID_SECS))) 23 | key = base64.b64decode(self.keyvalue.encode('utf-8')) 24 | sig = '{}\n{}'.format(uri, expiry).encode('utf-8') 25 | 26 | signature = urllib.quote( 27 | base64.b64encode(hmac.HMAC(key, sig, hashlib.sha256).digest()) 28 | ).replace('/', '%2F') 29 | 30 | token = 'SharedAccessSignature sig={}&se={}&skn={}&sr={}'.format( 31 | signature, expiry, self.keyname, uri.lower()) 32 | return token 33 | 34 | def _azureMessage(self, devid, prop, appdata): 35 | """Map the received application data to the Azure 36 | IoT D2C message format. 37 | 38 | This method maps the port value to pre-defined telemetry 39 | properties and forms the Azure IoT message using the matched 40 | property. 41 | 42 | Args: 43 | device (str): Azure DeviceID 44 | prop (AppProperty): Associated applicaiton property 45 | appdata (str): Application data 46 | 47 | """ 48 | value = prop.value(appdata) 49 | if value is None: 50 | return None 51 | 52 | data = '{{"deviceId": "{}", "{}": {}}}'.format(devid, prop.name, value) 53 | return data 54 | 55 | 56 | -------------------------------------------------------------------------------- /floranet/appserver/file_text_store.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from twisted.internet.defer import inlineCallbacks, returnValue 4 | from flask_restful import fields, marshal 5 | 6 | from floranet.models.model import Model 7 | 8 | class FileTextStore(Model): 9 | """File text storage application server interface 10 | 11 | This appserver interface saves data received to a file. 12 | 13 | Attributes: 14 | name (str): Application interface name 15 | file (str): File name 16 | running (bool): Running flag 17 | """ 18 | 19 | TABLENAME = 'appif_file_text_store' 20 | HASMANY = [{'name': 'appinterfaces', 'class_name': 'AppInterface', 'as': 'interfaces'}] 21 | 22 | def afterInit(self): 23 | self.started = False 24 | self.appinterface = None 25 | 26 | @inlineCallbacks 27 | def start(self, netserver): 28 | """Start the application interface 29 | 30 | Args: 31 | netserver (NetServer): The LoRa network server 32 | 33 | Returns True on success, False otherwise 34 | """ 35 | self.netserver = netserver 36 | self.started = True 37 | returnValue(True) 38 | yield 39 | 40 | def stop(self): 41 | """Stop the application interface""" 42 | # Reflector does not require any shutdown 43 | self.started = False 44 | return 45 | 46 | @inlineCallbacks 47 | def valid(self): 48 | """Validate a FileTextStore object. 49 | 50 | Returns: 51 | valid (bool), message(dict): (True, empty) on success, 52 | (False, error message dict) otherwise. 53 | """ 54 | messages = {} 55 | 56 | # Check the file path 57 | (path, name) = os.path.split(self.file) 58 | if path and not os.path.isdir(path): 59 | messages['file'] = "Directory {} does not exist".format(path) 60 | 61 | valid = not any(messages) 62 | returnValue((valid, messages)) 63 | yield 64 | 65 | def marshal(self): 66 | """Get REST API marshalled fields as an orderedDict 67 | 68 | Returns: 69 | OrderedDict of fields defined by marshal_fields 70 | """ 71 | marshal_fields = { 72 | 'type': fields.String(attribute='__class__.__name__'), 73 | 'id': fields.Integer(attribute='appinterface.id'), 74 | 'name': fields.String, 75 | 'file': fields.String, 76 | 'started': fields.Boolean 77 | } 78 | return marshal(self, marshal_fields) 79 | 80 | def netServerReceived(self, device, app, port, appdata): 81 | """Receive a application message from LoRa network server""" 82 | 83 | # Write data to our file, append and create if it doesn't exist. 84 | fp = open(self.file, 'a+') 85 | fp.write(appdata) 86 | fp.close() 87 | 88 | 89 | def datagramReceived(self, data, (host, port)): 90 | """Receive inbound application server data""" 91 | pass 92 | 93 | -------------------------------------------------------------------------------- /floranet/appserver/reflector.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.defer import inlineCallbacks, returnValue 2 | from flask_restful import fields, marshal 3 | 4 | from floranet.models.model import Model 5 | 6 | class Reflector(Model): 7 | """LoRa reflector application server interface 8 | 9 | This appserver interface bounces any messages received 10 | from a device back to that device. 11 | 12 | Attributes: 13 | name (str): Application interface name 14 | running (bool): Running flag 15 | """ 16 | 17 | TABLENAME = 'appif_reflector' 18 | HASMANY = [{'name': 'appinterfaces', 'class_name': 'AppInterface', 'as': 'interfaces'}] 19 | 20 | def afterInit(self): 21 | self.started = False 22 | self.appinterface = None 23 | 24 | @inlineCallbacks 25 | def start(self, netserver): 26 | """Start the application interface 27 | 28 | Args: 29 | netserver (NetServer): The LoRa network server 30 | 31 | Returns True on success, False otherwise 32 | """ 33 | self.netserver = netserver 34 | self.started = True 35 | returnValue(True) 36 | yield 37 | 38 | def stop(self): 39 | """Stop the application interface""" 40 | # Reflector does not require any shutdown 41 | self.started = False 42 | return 43 | 44 | @inlineCallbacks 45 | def valid(self): 46 | """Validate a Reflector object. 47 | 48 | Returns: 49 | valid (bool), message(dict): (True, empty) on success, 50 | (False, error message dict) otherwise. 51 | """ 52 | returnValue((True, {})) 53 | yield 54 | 55 | def marshal(self): 56 | """Get REST API marshalled fields as an orderedDict 57 | 58 | Returns: 59 | OrderedDict of fields defined by marshal_fields 60 | """ 61 | marshal_fields = { 62 | 'type': fields.String(attribute='__class__.__name__'), 63 | 'id': fields.Integer(attribute='appinterface.id'), 64 | 'name': fields.String, 65 | 'started': fields.Boolean 66 | } 67 | return marshal(self, marshal_fields) 68 | 69 | def netServerReceived(self, device, app, port, appdata): 70 | """Receive a application message from LoRa network server""" 71 | # Send the message to the network server 72 | self.netserver.inboundAppMessage(device.devaddr, appdata) 73 | 74 | def datagramReceived(self, data, (host, port)): 75 | """Receive inbound application server data""" 76 | pass 77 | 78 | -------------------------------------------------------------------------------- /floranet/commands/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | import requests 3 | version = 1.0 4 | 5 | def restRequest(server, url, txn, payload, expected): 6 | """Send a request to the server 7 | 8 | Args: 9 | server (str): host:port string 10 | url (str): Request URL 11 | txn (str): HTTP verb 12 | payload (dict): Parameters for JSON payload 13 | expected (int): Successful response code 14 | 15 | Returns: 16 | data dict on success, None otherwise 17 | """ 18 | # Make the request 19 | try: 20 | r = getattr(requests, txn)(url, json=payload) 21 | except requests.exceptions.ConnectionError: 22 | click.echo('Could not connect to the server at ' 23 | '{}'.format(server)) 24 | return None 25 | 26 | data = r.json() 27 | if r.status_code == expected: 28 | return data 29 | 30 | elif r.status_code == 400: 31 | if len(data['message']) > 1: 32 | click.echo('The following errors occurred:') 33 | indent = ' ' * 10 34 | for m in data['message'].itervalues(): 35 | click.echo(indent + m) 36 | else: 37 | click.echo('Error: ' + data['message'].itervalues().next()) 38 | elif r.status_code == 401: 39 | click.echo('Authentication failure: check credentials.') 40 | elif r.status_code == 404: 41 | click.echo('Error: ' + data['message'].itervalues().next()) 42 | elif r.status_code == 500: 43 | click.echo('An internal server error occurred.') 44 | else: 45 | click.echo('An unknown error occurred: status code = ' 46 | '{}'.format(r.status_code)) 47 | return None 48 | -------------------------------------------------------------------------------- /floranet/commands/app.py: -------------------------------------------------------------------------------- 1 | import click 2 | from floranet.util import euiString, devaddrString, intHexString, hexStringInt 3 | from floranet.commands import version, restRequest 4 | 5 | @click.group() 6 | def app(): 7 | pass 8 | 9 | @app.command() 10 | @click.pass_context 11 | @click.argument('appeui', nargs=1) 12 | def show(ctx, appeui): 13 | """show an application, or all applications. 14 | 15 | Args: 16 | ctx (Context): Click context 17 | appeui (str): Application EUI 18 | """ 19 | if '.' in appeui: 20 | appeui = str(hexStringInt(str(appeui))) 21 | 22 | # Form the url and payload 23 | server = ctx.obj['server'] 24 | payload = {'token': ctx.obj['token']} 25 | url = 'http://{}/api/v{}'.format(server, str(version)) 26 | url += '/apps' if appeui == 'all' else '/app/{}'.format(appeui) 27 | 28 | # Make the request 29 | data = restRequest(server, url, 'get', payload, 200) 30 | if data is None: 31 | return 32 | 33 | # Single application 34 | if appeui != 'all': 35 | a = data 36 | indent = ' ' * 10 37 | if a['appinterface_id'] == 0: 38 | a['appinterface_id'] = '-' 39 | if a['domain'] is None: 40 | a['domain'] = '-' 41 | click.echo('Application EUI: ' + euiString(a['appeui'])) 42 | click.echo('{}name: {}'.format(indent, a['name'])) 43 | click.echo('{}domain: {}'.format(indent, a['domain'])) 44 | click.echo('{}fport: {}'.format(indent, a['fport'])) 45 | click.echo('{}interface: {}'.format(indent, a['appinterface_id'])) 46 | if a['appinterface_id'] != '-': 47 | click.echo('{}Properties:'.format(indent)) 48 | properties = sorted(a['properties'].values(), key=lambda k: k['port']) 49 | for p in properties: 50 | click.echo('{} {} {}:{}'.format(indent, p['port'], p['name'], p['type'])) 51 | return 52 | 53 | # All applications 54 | click.echo('{:14}'.format('Application') + \ 55 | '{:24}'.format('AppEUI') + \ 56 | '{:15}'.format('Domain') + \ 57 | '{:6}'.format('Fport') + \ 58 | '{:10}'.format('Interface')) 59 | for i,a in data.iteritems(): 60 | if a['appinterface_id'] == 0: 61 | a['appinterface_id'] = '-' 62 | if a['domain'] is None: 63 | a['domain'] = '-' 64 | click.echo('{:13.13}'.format(a['name']) + ' ' + \ 65 | '{:23}'.format(euiString(a['appeui'])) + ' ' + \ 66 | '{:14.14}'.format(a['domain']) + ' ' + \ 67 | '{:5.5}'.format(str(a['fport'])) + ' ' + \ 68 | '{:10}'.format(str(a['appinterface_id']))) 69 | 70 | @app.command(context_settings=dict( 71 | ignore_unknown_options=True, allow_extra_args=True)) 72 | @click.argument('appeui') 73 | @click.pass_context 74 | def add(ctx, appeui): 75 | """add an application. 76 | 77 | Args: 78 | ctx (Context): Click context 79 | appeui (str): Application EUI string 80 | """ 81 | if '.' in appeui: 82 | appeui = str(hexStringInt(str(appeui))) 83 | 84 | # Translate kwargs to dict 85 | args = dict(item.split('=', 1) for item in ctx.args) 86 | 87 | # Check for required args 88 | required = ['name', 'appnonce', 'appkey', 'fport'] 89 | 90 | missing = [item for item in required if item not in args.keys()] 91 | if missing: 92 | if len(missing) == 1: 93 | click.echo("Missing argument " + missing[0]) 94 | else: 95 | click.echo("Missing arguments: " + ' '.join(missing)) 96 | return 97 | 98 | args['appnonce'] = int('0x' + str(args['appnonce']), 16) 99 | args['appkey'] = hexStringInt(str(args['appkey'])) 100 | 101 | # Create the payload 102 | server = ctx.obj['server'] 103 | payload = {'token': ctx.obj['token'], 'appeui': appeui} 104 | payload.update(args) 105 | 106 | # Perform a POST on /apps endpoint 107 | url = 'http://{}/api/v{}/apps'.format(server, str(version)) 108 | if restRequest(server, url, 'post', payload, 201) is None: 109 | return 110 | 111 | @app.command(context_settings=dict( 112 | ignore_unknown_options=True, 113 | allow_extra_args=True)) 114 | @click.argument('appeui') 115 | @click.pass_context 116 | def set(ctx, appeui): 117 | """modify an application. 118 | 119 | Args: 120 | ctx (Context): Click context 121 | appeui (str): Device EUI string 122 | """ 123 | 124 | if '.' in appeui: 125 | appeui = str(hexStringInt(str(appeui))) 126 | 127 | args = dict(item.split('=', 1) for item in ctx.args) 128 | if 'appkey' in args: 129 | args['appkey'] = hexStringInt(str(args['appkey'])) 130 | 131 | # Enabled is a yes or no 132 | if 'enabled' in args: 133 | args['enabled'] = args['enabled'].lower() == 'yes' 134 | 135 | # Add the kwargs to payload as a dict 136 | server = ctx.obj['server'] 137 | payload = {'token': ctx.obj['token']} 138 | 139 | for item in args: 140 | payload.update(args) 141 | 142 | # Perform a PUT on /app/appeui endpoint 143 | url = 'http://{}/api/v1.0/app/{}'.format(server, appeui) 144 | if restRequest(server, url, 'put', payload, 200) is None: 145 | return 146 | 147 | @app.command() 148 | @click.argument('appeui') 149 | @click.pass_context 150 | def delete(ctx, appeui): 151 | """delete an application. 152 | 153 | Args: 154 | ctx (Context): Click context 155 | appeui (str): Application EUI string 156 | """ 157 | if '.' in appeui: 158 | appeui = str(hexStringInt(str(appeui))) 159 | 160 | # Add the kwargs to payload as a dict 161 | server = ctx.obj['server'] 162 | payload = {'token': ctx.obj['token']} 163 | 164 | # Perform a DELETE on /app/appeui endpoint 165 | url = 'http://{}/api/v1.0/app/{}'.format(server, appeui) 166 | if restRequest(server, url, 'delete', payload, 200) is None: 167 | return 168 | 169 | -------------------------------------------------------------------------------- /floranet/commands/device.py: -------------------------------------------------------------------------------- 1 | import click 2 | from floranet.util import euiString, devaddrString, intHexString, hexStringInt 3 | from floranet.commands import version, restRequest 4 | 5 | @click.group() 6 | def device(): 7 | pass 8 | 9 | @device.command() 10 | @click.pass_context 11 | @click.argument('deveui', nargs=1) 12 | def show(ctx, deveui): 13 | """show a device, or all devices. 14 | 15 | Args: 16 | ctx (Context): Click context 17 | deveui (str): Device EUI string 18 | """ 19 | if '.' in deveui: 20 | deveui = str(hexStringInt(str(deveui))) 21 | 22 | # Form the url and payload 23 | server = ctx.obj['server'] 24 | payload = {'token': ctx.obj['token']} 25 | url = 'http://{}/api/v{}'.format(server, version) 26 | url += '/devices' if deveui == 'all' else '/device/{}'.format(deveui) 27 | 28 | # Make the request 29 | data = restRequest(server, url, 'get', payload, 200) 30 | if data is None: 31 | return 32 | 33 | # Single device 34 | if deveui != 'all': 35 | d = data 36 | indent = ' ' * 10 37 | enable = 'enabled' if d['enabled'] else 'disabled' 38 | drate = d['tx_datr'] if d['tx_datr'] else 'N/A' 39 | nwkid = hex(d['devaddr'] >> 25) 40 | snrav = '{0:.2f} dBm'.format(d['snr_average']) if d['snr_average'] else 'N/A' 41 | appname = d['appname'] if d['appname'] else 'N/A' 42 | lat = '{0:.4f}'.format(d['latitude']) if d['latitude'] else 'N/A' 43 | lon = '{0:.4f}'.format(d['longitude']) if d['longitude'] else 'N/A' 44 | activ = 'Over the air (OTAA)' if d['otaa'] else 'Personalization (ABP)' 45 | click.echo('Device EUI: ' + euiString(d['deveui'])) 46 | click.echo(indent + 'device address ' + devaddrString(d['devaddr']) + \ 47 | ' nwkID ' + nwkid + ' ' + enable) 48 | click.echo(indent + 'name: ' + d['name']) 49 | click.echo(indent + 'class: ' + d['devclass'].upper()) 50 | click.echo(indent + 'application EUI: ' + euiString(d['appeui'])) 51 | click.echo(indent + 'activation: ' + activ) 52 | click.echo(indent + 'appname: ' + appname) 53 | click.echo(indent + 'latitude: ' + lat) 54 | click.echo(indent + 'longitude: ' + lon) 55 | if not d['otaa']: 56 | click.echo(indent + 'appskey: ' + intHexString(d['appskey'], 16)) 57 | click.echo(indent + 'nwkskey: ' + intHexString(d['nwkskey'], 16)) 58 | click.echo(indent + 'data rate: ' + drate) 59 | click.echo(indent + 'average SNR: ' + snrav) 60 | return 61 | 62 | # All devices 63 | click.echo('{:15}'.format('Device') + \ 64 | '{:24}'.format('DeviceEUI') + \ 65 | '{:12}'.format('DevAddr') + \ 66 | '{:9}'.format('Enabled') + \ 67 | '{:5}'.format('Act') + \ 68 | '{:12}'.format('Average-SNR')) 69 | for i,d in data.iteritems(): 70 | enable = 'Yes' if d['enabled'] else 'No' 71 | active = 'OTA' if d['otaa'] else 'ABP' 72 | snravg = '{0:.2f} dBm'.format(d['snr_average']) if d['snr_average'] else 'N/A' 73 | click.echo('{:14.14}'.format(d['name']) + ' ' + \ 74 | '{:23}'.format(euiString(d['deveui'])) + ' ' + \ 75 | '{:12}'.format(devaddrString(d['devaddr'])) + \ 76 | '{:9}'.format(enable) + \ 77 | '{:5}'.format(active) + \ 78 | '{:12}'.format(snravg)) 79 | 80 | @device.command(context_settings=dict( 81 | ignore_unknown_options=True, 82 | allow_extra_args=True)) 83 | @click.argument('deveui') 84 | @click.pass_context 85 | def add(ctx, deveui): 86 | """add a device. 87 | 88 | Args: 89 | ctx (Context): Click context 90 | deveui (str): Device EUI string 91 | """ 92 | if '.' in deveui: 93 | deveui = str(hexStringInt(str(deveui))) 94 | 95 | # Translate kwargs to dict 96 | args = dict(item.split('=', 1) for item in ctx.args) 97 | 98 | def check_missing(args, required): 99 | missing = [item for item in required if item not in args.keys()] 100 | if not missing: 101 | return True 102 | if len(missing) == 1: 103 | click.echo("Missing argument " + missing[0]) 104 | else: 105 | click.echo("Missing arguments: " + ' '.join(missing)) 106 | return False 107 | 108 | required = ['name', 'class', 'enabled', 'otaa', 'appeui'] 109 | if not check_missing(args, required): 110 | return 111 | 112 | # Convert appeui 113 | args['appeui'] = hexStringInt(str(args['appeui'])) 114 | 115 | # Convert class 116 | args['devclass'] = args.pop('class').lower() 117 | 118 | # Enabled is a yes or no 119 | args['enabled'] = True if args['enabled'].lower() == 'yes' else False 120 | 121 | # If OTAA is false, we must have devaddr, nwkskey and appskey 122 | args['otaa'] = True if args['otaa'].lower() == 'yes' else False 123 | if not args['otaa']: 124 | required = ['appskey', 'nwkskey', 'devaddr'] 125 | if not check_missing(args, required): 126 | return 127 | for r in required: 128 | args[r] = hexStringInt(str(args[r])) 129 | 130 | # Create the payload 131 | server = ctx.obj['server'] 132 | payload = {'token': ctx.obj['token'], 'deveui': deveui} 133 | payload.update(args) 134 | 135 | # Perform a POST on /devices endpoint 136 | url = 'http://{}/api/v{}/devices'.format(server, version) 137 | if restRequest(server, url, 'post', payload, 201) is None: 138 | return 139 | 140 | @device.command(context_settings=dict( 141 | ignore_unknown_options=True, 142 | allow_extra_args=True)) 143 | @click.argument('deveui') 144 | @click.pass_context 145 | def set(ctx, deveui): 146 | """modify a device. 147 | 148 | Args: 149 | ctx (Context): Click context 150 | deveui (str): Device EUI string 151 | """ 152 | 153 | deveui = hexStringInt(str(deveui)) 154 | 155 | # Translate kwargs to dict 156 | args = dict(item.split('=', 1) for item in ctx.args) 157 | 158 | # Enabled is a yes or no 159 | if 'enabled' in args: 160 | args['enabled'] = True if args['enabled'].lower() == 'yes' else False 161 | 162 | if 'class' in args: 163 | args['devclass'] = args.pop('class').lower() 164 | 165 | if 'nwkskey' in args: 166 | args['nwkskey'] = hexStringInt(str(args['nwkskey'])) 167 | 168 | if 'appskey' in args: 169 | args['appskey'] = hexStringInt(str(args['appskey'])) 170 | 171 | # Add the args to payload 172 | server = ctx.obj['server'] 173 | payload = {'token': ctx.obj['token']} 174 | payload.update(args) 175 | 176 | # Perform a PUT on /device/deveui endpoint 177 | url = 'http://{}/api/v{}/device/{}'.format(server, version, deveui) 178 | if restRequest(server, url, 'put', payload, 200) is None: 179 | return 180 | 181 | def state(ctx, deveui, enabled): 182 | """Issue a PUT request to enable or disable a device. 183 | 184 | Args: 185 | ctx (): Click context 186 | deveui (int): Device EUI 187 | enabled (bool): Enable/disable flag 188 | """ 189 | if ':' in deveui: 190 | deveui = str(hexStringInt(str(deveui))) 191 | 192 | server = ctx.obj['server'] 193 | payload = {'token': ctx.obj['token'], 194 | 'enabled': enabled} 195 | 196 | # Perform a PUT on /device/deveui endpoint 197 | url = 'http://{}/api/v{}/device/{}'.format(server, version, deveui) 198 | if restRequest(server, url, 'put', payload, 200) is None: 199 | return 200 | 201 | e = 'enabled' if enabled else 'disabled' 202 | 203 | @device.command() 204 | @click.pass_context 205 | @click.argument('deveui', nargs=1) 206 | def enable(ctx, deveui): 207 | """enable a device. 208 | 209 | Args: 210 | ctx (Context): Click context 211 | deveui (str): Device EUI string 212 | """ 213 | state(ctx, deveui, True) 214 | 215 | @device.command() 216 | @click.pass_context 217 | @click.argument('deveui', nargs=1) 218 | def disable(ctx, deveui): 219 | """disable a device. 220 | 221 | Args: 222 | ctx (Context): Click context 223 | deveui (str): Device EUI string 224 | """ 225 | state(ctx, deveui, False) 226 | 227 | @device.command() 228 | @click.argument('deveui') 229 | @click.pass_context 230 | def delete(ctx, deveui): 231 | """delete a device. 232 | 233 | Args: 234 | ctx (Context): Click context 235 | deveui (str): Device EUI string 236 | """ 237 | if '.' in deveui: 238 | deveui = str(hexStringInt(str(deveui))) 239 | 240 | # Add the kwargs to payload as a dict 241 | server = ctx.obj['server'] 242 | payload = {'token': ctx.obj['token']} 243 | 244 | # Perform a DELETE on /device/deveui endpoint 245 | url = 'http://{}/api/v{}/device/{}'.format(server, version, deveui) 246 | if restRequest(server, url, 'delete', payload, 200) is None: 247 | return 248 | 249 | -------------------------------------------------------------------------------- /floranet/commands/gateway.py: -------------------------------------------------------------------------------- 1 | import click 2 | from floranet.util import euiString, hexStringInt 3 | from floranet.commands import version, restRequest 4 | 5 | @click.group() 6 | def gateway(): 7 | pass 8 | 9 | @gateway.command() 10 | @click.pass_context 11 | @click.argument('host', nargs=1) 12 | def show(ctx, host): 13 | """show a gateway, or all gateways 14 | 15 | Args: 16 | ctx (Context): Click context 17 | host (str): Gateway IP address 18 | """ 19 | # Form the url and payload 20 | server = ctx.obj['server'] 21 | payload = {'token': ctx.obj['token']} 22 | if host == 'all': 23 | url = 'http://' + server + '/api/v' + str(version) + \ 24 | '/gateways' 25 | else: 26 | url = 'http://' + server + '/api/v' + str(version) + \ 27 | '/gateway/' + host 28 | 29 | # Make the request 30 | data = restRequest(server, url, 'get', payload, 200) 31 | if data is None: 32 | return 33 | 34 | # Print a gateway 35 | if host != 'all': 36 | g = data 37 | status = 'enabled' if g['enabled'] else 'disabled' 38 | indent = ' ' * 10 39 | click.echo(g['host'] + ': ' + g['name']) 40 | click.echo(indent + 'eui ' + euiString(g['eui'])) 41 | click.echo(indent + 'power: ' + str(g['power']) + ' dBm') 42 | click.echo(indent + 'status: ' + status) 43 | return 44 | 45 | # Print all gateways 46 | click.echo('{:15}'.format('Gateway') + '{:17}'.format('IP-Address') + \ 47 | '{:24}'.format('EUI') + \ 48 | '{:9}'.format('Enabled') + '{:12}'.format('Power-dBm')) 49 | for i,g in data.iteritems(): 50 | enabled = 'Yes' if g['enabled'] else 'No' 51 | click.echo('{:14.14}'.format(g['name']) + ' ' + \ 52 | '{:17}'.format(g['host']) + \ 53 | '{:24}'.format(euiString(g['eui'])) + \ 54 | '{:9}'.format(enabled) + \ 55 | '{:2}'.format(g['power'])) 56 | 57 | @gateway.command(context_settings=dict( 58 | ignore_unknown_options=True, 59 | allow_extra_args=True)) 60 | @click.argument('host') 61 | @click.pass_context 62 | def add(ctx, host): 63 | """add a gateway. 64 | 65 | Args: 66 | ctx (Context): Click context 67 | host (str): Gateway IP address 68 | """ 69 | # Translate args to dict 70 | args = dict(item.split('=', 1) for item in ctx.args) 71 | 72 | required = {'name' , 'eui', 'enabled', 'power'} 73 | missing = [item for item in required if item not in args.keys()] 74 | if missing: 75 | if len(missing) == 1: 76 | click.echo("Missing argument " + missing[0]) 77 | else: 78 | click.echo("Missing arguments: " + ' '.join(missing)) 79 | return 80 | 81 | args['enabled'] = True if args['enabled'] == 'yes' else False 82 | args['eui'] = hexStringInt(str(args['eui'])) 83 | 84 | # Create the payload 85 | server = ctx.obj['server'] 86 | payload = {'token': ctx.obj['token'], 'host': host} 87 | payload.update(args) 88 | 89 | # Perform a POST on /gateways endpoint 90 | url = 'http://' + server + '/api/v1.0/gateways' 91 | if restRequest(server, url, 'post', payload, 201) is None: 92 | return 93 | 94 | @gateway.command(context_settings=dict( 95 | ignore_unknown_options=True, 96 | allow_extra_args=True)) 97 | @click.argument('host') 98 | @click.pass_context 99 | def modify(ctx, host): 100 | """modify a gateway. 101 | 102 | Args: 103 | ctx (Context): Click context 104 | host (str): Gateway IP address 105 | """ 106 | server = ctx.obj['server'] 107 | # Add the kwargs to payload as a dict 108 | payload = {'token': ctx.obj['token']} 109 | for item in ctx.args: 110 | payload.update([item.split('=')]) 111 | 112 | # Convert EUI to integer 113 | if 'eui' in payload: 114 | payload['eui'] = hexStringInt(str(payload['eui'])) 115 | 116 | # Enabled is a yes or no 117 | if 'enabled' in payload: 118 | payload['enabled'] = payload['enabled'].lower() == 'yes' 119 | 120 | # Perform a PUT on /gateway/host endpoint 121 | url = 'http://' + server + '/api/v1.0/gateway/' + host 122 | if restRequest(server, url, 'put', payload, 200) is None: 123 | return 124 | 125 | def state(ctx, host, enabled): 126 | """Enable or disable a gateway. 127 | 128 | Args: 129 | ctx (Context): Click context 130 | host (str): Gateway IP address 131 | enabled (bool): Enable/disable flag 132 | """ 133 | server = ctx.obj['server'] 134 | payload = {'token': ctx.obj['token'], 135 | 'enabled': enabled} 136 | 137 | # Perform a PUT on /gateway/host endpoint 138 | url = 'http://' + ctx.obj['server'] + '/api/v1.0/gateway/' + host 139 | if restRequest(server, url, 'put', payload, 200) is None: 140 | return 141 | 142 | e = 'enabled' if enabled else 'disabled' 143 | 144 | @gateway.command() 145 | @click.pass_context 146 | @click.argument('host', nargs=1) 147 | def enable(ctx, host): 148 | """enable a gateway. 149 | 150 | Args: 151 | ctx (Context): Click context 152 | host (str): Gateway IP address 153 | """ 154 | state(ctx, host, True) 155 | 156 | @gateway.command() 157 | @click.pass_context 158 | @click.argument('host', nargs=1) 159 | def disable(ctx, host): 160 | """disable a gateway. 161 | 162 | Args: 163 | ctx (Context): Click context 164 | host (str): Gateway IP address 165 | """ 166 | state(ctx, host, False) 167 | 168 | @gateway.command() 169 | @click.argument('host') 170 | @click.pass_context 171 | def delete(ctx, host): 172 | """delete a gateway. 173 | 174 | Args: 175 | ctx (Context): Click context 176 | host (str): Gateway IP address 177 | """ 178 | server = ctx.obj['server'] 179 | payload = {'token': ctx.obj['token']} 180 | 181 | # Perform a DELETE on /gateway/host endpoint 182 | url = 'http://' + server + '/api/v1.0/gateway/' + host 183 | if restRequest(server, url, 'delete', payload, 200) is None: 184 | return 185 | 186 | -------------------------------------------------------------------------------- /floranet/commands/interface.py: -------------------------------------------------------------------------------- 1 | import click 2 | from floranet.util import euiString, devaddrString, intHexString, hexStringInt 3 | from floranet.commands import version, restRequest 4 | 5 | @click.group() 6 | def interface(): 7 | pass 8 | 9 | @interface.command() 10 | @click.pass_context 11 | @click.argument('id', nargs=1) 12 | def show(ctx, id): 13 | """show an interface, or all interfaces. 14 | 15 | Args: 16 | ctx (Context): Click context 17 | id (int): Application interface ID 18 | """ 19 | # Form the url and payload 20 | server = ctx.obj['server'] 21 | payload = {'token': ctx.obj['token']} 22 | url = 'http://{}/api/v{}'.format(server, str(version)) 23 | url += '/interfaces' if id == 'all' else '/interface/{}'.format(id) 24 | 25 | # Make the request 26 | data = restRequest(server, url, 'get', payload, 200) 27 | if data is None: 28 | return 29 | 30 | # All interfaces 31 | if id == 'all': 32 | click.echo('{:4}'.format('ID') + \ 33 | '{:24}'.format('Name') + \ 34 | '{:15}'.format('Type')) 35 | for i,a in sorted(data.iteritems()): 36 | if a['type'] == 'AzureIotHttps': 37 | a['type'] = 'Azure HTTPS' 38 | elif a['type'] == 'AzureIotMqtt': 39 | a['type'] = 'Azure MQTT' 40 | elif a['type'] == 'FileTextStore': 41 | a['type'] = 'Text File' 42 | click.echo('{:3}'.format(a['id']) + ' ' + \ 43 | '{:23}'.format(a['name']) + ' ' + \ 44 | '{:14}'.format(a['type'])) 45 | return 46 | 47 | # Single interface 48 | i = data 49 | indent = ' ' * 10 50 | started = 'Started' if i['started'] else 'Stopped' 51 | 52 | if i['type'] == 'Reflector': 53 | click.echo('{}name: {}'.format(indent, i['name'])) 54 | click.echo('{}type: {}'.format(indent, i['type'])) 55 | click.echo('{}status: {}'.format(indent, started)) 56 | 57 | elif i['type'] == 'FileTextStore': 58 | click.echo('{}name: {}'.format(indent, i['name'])) 59 | click.echo('{}type: {}'.format(indent, i['type'])) 60 | click.echo('{}status: {}'.format(indent, started)) 61 | click.echo('{}file: {}'.format(indent, i['file'])) 62 | 63 | elif i['type'] == 'AzureIotHttps': 64 | protocol = 'HTTPS' 65 | click.echo('{}name: {}'.format(indent, i['name'])) 66 | click.echo('{}protocol: {}'.format(indent, protocol)) 67 | click.echo('{}key name: {}'.format(indent, i['keyname'])) 68 | click.echo('{}key value: {}'.format(indent, i['keyvalue'])) 69 | click.echo('{}Polling interval: {} minutes'. 70 | format(indent, i['poll_interval'])) 71 | click.echo('{}status: {}'.format(indent, started)) 72 | 73 | elif i['type'] == 'AzureIotMqtt': 74 | protocol = 'MQTT' 75 | click.echo('{}name: {}'.format(indent, i['name'])) 76 | click.echo('{}protocol: {}'.format(indent, protocol)) 77 | click.echo('{}key name: {}'.format(indent, i['keyname'])) 78 | click.echo('{}key value: {}'.format(indent, i['keyvalue'])) 79 | click.echo('{}status: {}'.format(indent, started)) 80 | return 81 | 82 | @interface.command(context_settings=dict( 83 | ignore_unknown_options=True, allow_extra_args=True)) 84 | @click.argument('type') 85 | @click.pass_context 86 | def add(ctx, type): 87 | """add an interface. 88 | 89 | Args: 90 | ctx (Context): Click context 91 | iftype (str): Application interface type 92 | """ 93 | 94 | # Translate kwargs to dict 95 | args = dict(item.split('=', 1) for item in ctx.args) 96 | 97 | iftype = type.lower() 98 | types = {'reflector', 'azure', 'filetext'} 99 | 100 | # Check for required args 101 | if not iftype in types: 102 | click.echo("Unknown interface type") 103 | return 104 | 105 | required = {'reflector': ['name'], 106 | 'filetext': ['name', 'file'], 107 | 'azure': ['protocol', 'name' , 'iothost', 'keyname', 108 | 'keyvalue'] 109 | } 110 | 111 | missing = [item for item in required[iftype] if item not in args.keys()] 112 | 113 | if type == 'azure' and 'protocol' == 'https' and not 'pollinterval' in args.keys(): 114 | missing.append('pollinterval') 115 | 116 | if missing: 117 | if len(missing) == 1: 118 | click.echo("Missing argument " + missing[0]) 119 | else: 120 | click.echo("Missing arguments: " + ' '.join(missing)) 121 | return 122 | 123 | # Create the payload 124 | server = ctx.obj['server'] 125 | payload = {'token': ctx.obj['token'], 'type': iftype} 126 | payload.update(args) 127 | 128 | # Perform a POST on /apps endpoint 129 | url = 'http://{}/api/v{}/interfaces'.format(server, str(version)) 130 | if restRequest(server, url, 'post', payload, 201) is None: 131 | return 132 | 133 | @interface.command(context_settings=dict( 134 | ignore_unknown_options=True, 135 | allow_extra_args=True)) 136 | @click.argument('id') 137 | @click.pass_context 138 | def set(ctx, id): 139 | """Modify an interface. 140 | 141 | Args: 142 | ctx (Context): Click context 143 | id (str): App interface id 144 | """ 145 | 146 | # Add the kwargs to payload as a dict 147 | server = ctx.obj['server'] 148 | payload = {'token': ctx.obj['token']} 149 | for item in ctx.args: 150 | payload.update([item.split('=')]) 151 | 152 | # Enabled is a yes or no 153 | if 'enabled' in payload: 154 | payload['enabled'] = payload['enabled'].lower() == 'yes' 155 | 156 | # Perform a PUT on /app/appeui endpoint 157 | url = 'http://{}/api/v1.0/interface/{}'.format(server, id) 158 | if restRequest(server, url, 'put', payload, 200) is None: 159 | return 160 | 161 | @interface.command() 162 | @click.argument('id') 163 | @click.pass_context 164 | def delete(ctx, id): 165 | """delete an interface. 166 | 167 | Args: 168 | ctx (Context): Click context 169 | id (str): Application interface id 170 | """ 171 | # Add the kwargs to payload as a dict 172 | server = ctx.obj['server'] 173 | payload = {'token': ctx.obj['token']} 174 | 175 | # Perform a DELETE on /interface/id endpoint 176 | url = 'http://{}/api/v1.0/interface/{}'.format(server, id) 177 | if restRequest(server, url, 'delete', payload, 200) is None: 178 | return 179 | -------------------------------------------------------------------------------- /floranet/commands/property.py: -------------------------------------------------------------------------------- 1 | import click 2 | from floranet.util import euiString, devaddrString, intHexString, hexStringInt 3 | from floranet.commands import version, restRequest 4 | 5 | @click.group() 6 | def property(): 7 | pass 8 | 9 | @property.command(context_settings=dict( 10 | ignore_unknown_options=True, allow_extra_args=True)) 11 | @click.argument('appeui') 12 | @click.pass_context 13 | def add(ctx, appeui): 14 | """add an application property. 15 | 16 | Args: 17 | ctx (Context): Click context 18 | appeui (str): Application EUI string 19 | """ 20 | if '.' in appeui: 21 | appeui = str(hexStringInt(str(appeui))) 22 | 23 | # Translate kwargs to dict 24 | args = dict(item.split('=', 1) for item in ctx.args) 25 | 26 | # Check for required args 27 | required = ['port', 'name', 'type'] 28 | 29 | missing = [item for item in required if item not in args.keys()] 30 | if missing: 31 | if len(missing) == 1: 32 | click.echo("Missing argument " + missing[0]) 33 | else: 34 | click.echo("Missing arguments: " + ' '.join(missing)) 35 | return 36 | 37 | # Create the payload 38 | server = ctx.obj['server'] 39 | payload = {'token': ctx.obj['token'], 'appeui': appeui} 40 | payload.update(args) 41 | 42 | # Perform a POST on /propertys endpoint 43 | url = 'http://{}/api/v{}/propertys'.format(server, str(version)) 44 | if restRequest(server, url, 'post', payload, 201) is None: 45 | return 46 | 47 | @property.command(context_settings=dict( 48 | ignore_unknown_options=True, 49 | allow_extra_args=True)) 50 | @click.argument('appeui') 51 | @click.pass_context 52 | def set(ctx, appeui): 53 | """modify an application property. 54 | 55 | Args: 56 | ctx (Context): Click context 57 | appeui (str): Device EUI string 58 | """ 59 | 60 | if '.' in appeui: 61 | appeui = str(hexStringInt(str(appeui))) 62 | 63 | # Translate kwargs to dict 64 | args = dict(item.split('=', 1) for item in ctx.args) 65 | 66 | # Port (int) is mandatory 67 | if not 'port' in args.keys(): 68 | click.echo("Missing the port argument.") 69 | return 70 | if not isinstance(args['port'], (int, long)): 71 | click.echo("Port argument must be an integer.") 72 | return 73 | 74 | # Add the kwargs to payload as a dict 75 | server = ctx.obj['server'] 76 | payload = {'token': ctx.obj['token']} 77 | for item in ctx.args: 78 | payload.update([item.split('=')]) 79 | 80 | # Perform a PUT on /property/appeui endpoint 81 | url = 'http://{}/api/v1.0/property/{}'.format(server, appeui) 82 | if restRequest(server, url, 'put', payload, 200) is None: 83 | return 84 | 85 | @property.command() 86 | @click.argument('appeui') 87 | @click.pass_context 88 | def delete(ctx, appeui): 89 | """delete an application property. 90 | 91 | Args: 92 | ctx (Context): Click context 93 | appeui (str): Application EUI string 94 | """ 95 | if '.' in appeui: 96 | appeui = str(hexStringInt(str(appeui))) 97 | 98 | # Translate kwargs to dict 99 | args = dict(item.split('=', 1) for item in ctx.args) 100 | 101 | # Port (int) is mandatory 102 | if not 'port' in args.keys(): 103 | click.echo("Missing the port argument.") 104 | return 105 | if not isinstance(args['port'], (int, long)): 106 | click.echo("Port argument must be an integer.") 107 | return 108 | 109 | # Add the kwargs to payload as a dict 110 | server = ctx.obj['server'] 111 | payload = {'token': ctx.obj['token']} 112 | payload.update([args['port'].split('=')]) 113 | 114 | # Perform a DELETE on /property/appeui endpoint 115 | url = 'http://{}/api/v1.0/property/{}'.format(server, appeui) 116 | if restRequest(server, url, 'delete', payload, 200) is None: 117 | return 118 | 119 | -------------------------------------------------------------------------------- /floranet/commands/system.py: -------------------------------------------------------------------------------- 1 | import click 2 | from floranet.commands import version, restRequest 3 | from floranet.util import intHexString, devaddrString, hexStringInt 4 | 5 | @click.group() 6 | def system(): 7 | pass 8 | 9 | @system.command() 10 | @click.pass_context 11 | def show(ctx): 12 | """show the system configuration. 13 | 14 | Args: 15 | ctx (Context): Click context 16 | """ 17 | 18 | # Form the url and payload 19 | server = ctx.obj['server'] 20 | payload = {'token': ctx.obj['token']} 21 | url = 'http://{}/api/v{}/system'.format(server, str(version)) 22 | 23 | # Make the request 24 | data = restRequest(server, url, 'get', payload, 200) 25 | if data is None: 26 | return 27 | 28 | # Single application 29 | c = data 30 | indent = ' ' * 10 31 | click.echo('System: {} at {}'.format(c['name'], server)) 32 | click.echo('{}Network interface: {}'.format(indent, c['listen'])) 33 | click.echo('{}LoraWAN port: {}'.format(indent, c['port'])) 34 | click.echo('{}Web server port: {}'.format(indent, c['webport'])) 35 | click.echo('{}Frequency band: {}'.format(indent, c['freqband'])) 36 | click.echo('{}Network ID: 0x{}'.format(indent, 37 | intHexString(c['netid'], 3, sep=2))) 38 | click.echo('{}OTAA Address Range: 0x{} - 0x{}'.format(indent, 39 | devaddrString(c['otaastart']), devaddrString(c['otaaend']))) 40 | t = 'Yes' if c['adrenable'] else 'No' 41 | click.echo('{}ADR enabled: {}'.format(indent, t)) 42 | if c['adrenable']: 43 | click.echo('{}ADR margin: {} dB'.format(indent, c['adrmargin'])) 44 | click.echo('{}ADR cycle time: {} s'.format(indent, c['adrcycletime'])) 45 | click.echo('{}ADR message time: {} s'.format(indent, 46 | c['adrmessagetime'])) 47 | t = 'Yes' if c['fcrelaxed'] else 'No' 48 | click.echo('{}Relaxed frame count: {}'.format(indent, t)) 49 | t = 'Yes' if c['macqueueing'] else 'No' 50 | click.echo('{}MAC queueing: {}'.format(indent, t)) 51 | if c['macqueueing']: 52 | click.echo('{}MAC queue limit: {} s'.format(indent, 53 | c['macqueuelimit'])) 54 | return 55 | 56 | @system.command(context_settings=dict( 57 | ignore_unknown_options=True, allow_extra_args=True)) 58 | @click.pass_context 59 | def set(ctx): 60 | """set system configuration parameters. 61 | 62 | Args: 63 | ctx (Context): Click context 64 | """ 65 | # Translate kwargs to dict 66 | args = dict(item.split('=', 1) for item in ctx.args) 67 | 68 | if not args: 69 | return 70 | 71 | # Check for valid args 72 | valid = {'name', 'listen', 'port', 'webport', 'apitoken', 'freqband', 73 | 'netid', 'duplicateperiod', 'fcrelaxed', 'otaastart', 'otaaend', 74 | 'macqueueing', 'macqueuelimit', 'adrenable', 'adrmargin', 75 | 'adrcycletime', 'adrmessagetime'} 76 | bool_args = {'fcrelaxed', 'adrenable', 'macqueueing'} 77 | for arg,param in args.items(): 78 | if not arg in valid: 79 | click.echo('Invalid argument: {}'.format(arg)) 80 | return 81 | if arg in bool_args: 82 | args[arg] = True if param.lower() == 'yes' else False 83 | if arg in {'netid', 'otaastart', 'otaaend'}: 84 | args[arg] = hexStringInt(str(param)) 85 | 86 | # Form the url and payload 87 | server = ctx.obj['server'] 88 | url = 'http://{}/api/v{}/system'.format(server, str(version)) 89 | payload = {'token': ctx.obj['token']} 90 | payload.update(args) 91 | 92 | # Make the request 93 | data = restRequest(server, url, 'put', payload, 200) 94 | if data is None: 95 | return 96 | -------------------------------------------------------------------------------- /floranet/data/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # max length of characters to apply to the 11 | # "slug" field 12 | #truncate_slug_length = 40 13 | 14 | # set to 'true' to run the environment during 15 | # the 'revision' command, regardless of autogenerate 16 | # revision_environment = false 17 | 18 | # set to 'true' to allow .pyc and .pyo files without 19 | # a source .py file to be detected as revisions in the 20 | # versions/ directory 21 | # sourceless = false 22 | 23 | # version location specification; this defaults 24 | # to alembic/versions. When using multiple version 25 | # directories, initial revisions must be specified with --version-path 26 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 27 | 28 | # the output encoding used when revision files 29 | # are written from script.py.mako 30 | # output_encoding = utf-8 31 | 32 | # Logging configuration 33 | [loggers] 34 | keys = root,sqlalchemy,alembic 35 | 36 | [handlers] 37 | keys = console 38 | 39 | [formatters] 40 | keys = generic 41 | 42 | [logger_root] 43 | level = WARN 44 | handlers = console 45 | qualname = 46 | 47 | [logger_sqlalchemy] 48 | level = WARN 49 | handlers = 50 | qualname = sqlalchemy.engine 51 | 52 | [logger_alembic] 53 | level = INFO 54 | handlers = 55 | qualname = alembic 56 | 57 | [handler_console] 58 | class = StreamHandler 59 | args = (sys.stderr,) 60 | level = NOTSET 61 | formatter = generic 62 | 63 | [formatter_generic] 64 | format = %(levelname)-5.5s [%(name)s] %(message)s 65 | datefmt = %H:%M:%S 66 | -------------------------------------------------------------------------------- /floranet/data/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /floranet/data/alembic/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | import os 3 | 4 | from alembic import context 5 | from sqlalchemy import create_engine, engine_from_config, pool, exc 6 | from logging.config import fileConfig 7 | 8 | from floranet.database import Database 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | target_metadata = None 20 | 21 | def check_database(db): 22 | """Check the database exists. 23 | 24 | Args: 25 | db (Database): Database object 26 | 27 | Returns: 28 | True if the database exists, otherwise False 29 | """ 30 | url = "postgres://{user}:{password}@{host}/postgres".format(user=db.user, 31 | password=db.password, host=db.host) 32 | engine = create_engine(url) 33 | 34 | # Connect to the database 35 | try: 36 | conn = engine.connect() 37 | except exc.OperationalError: 38 | print "Error connecting to the postgres on " \ 39 | "host '{host}', user '{user}'. Check the host " \ 40 | "and user credentials.".format( 41 | host=db.host, user=db.user) 42 | exit(1) 43 | 44 | # Check if the database exists 45 | res = conn.execute('SELECT exists(SELECT 1 from pg_catalog.pg_database where datname = %s)', 46 | (db.database,)) 47 | exists = res.first()[0] 48 | return exists 49 | 50 | def run_migrations(db): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | url = "postgres://{user}:{password}@{host}/{database}".format( 58 | user=db.user, 59 | password=db.password, 60 | host=db.host, 61 | database=db.database) 62 | 63 | connectable = create_engine(url, poolclass=pool.NullPool) 64 | 65 | with connectable.connect() as connection: 66 | context.configure( 67 | connection=connection, 68 | target_metadata=target_metadata 69 | ) 70 | 71 | with context.begin_transaction(): 72 | context.run_migrations() 73 | 74 | # Get the database configuration file path 75 | cpath = os.path.dirname(os.path.realpath(__file__)) + '/../../../config/database.cfg' 76 | print 'Using database configuration file {cpath}'.format(cpath=os.path.abspath(cpath)) 77 | 78 | # Parse the database configuration 79 | db = Database() 80 | if not db.parseConfig(cpath): 81 | print 'Could not parse the database contiguration file.' 82 | exit(1) 83 | 84 | # Check the database exists 85 | if not check_database(db): 86 | print 'The database {database} does not exist on the host ' \ 87 | '{host}.'.format(database=db.database, host=db.host) 88 | exit(1) 89 | 90 | run_migrations(db) 91 | -------------------------------------------------------------------------------- /floranet/data/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | def upgrade(): 19 | ${upgrades if upgrades else "pass"} 20 | 21 | 22 | def downgrade(): 23 | ${downgrades if downgrades else "pass"} 24 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/03fabc9f542b_create_gateways_table_add_devnonce.py: -------------------------------------------------------------------------------- 1 | """create gateways table add device nonce array 2 | 3 | Revision ID: 03fabc9f542b 4 | Revises: b664cccf21a2 5 | Create Date: 2017-02-04 11:24:46.937314 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects.postgresql import INET 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '03fabc9f542b' 14 | down_revision = 'b664cccf21a2' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | op.create_table( 20 | 'gateways', 21 | sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), 22 | sa.Column('host', INET, nullable=False, unique=True), 23 | sa.Column('name', sa.String, nullable=True), 24 | sa.Column('enabled', sa.Boolean, nullable=False, default=True), 25 | sa.Column('eui', sa.Numeric, nullable=False, unique=True), 26 | sa.Column('power', sa.Integer, nullable=False), 27 | sa.Column('port', sa.String, nullable=True), 28 | sa.Column('latitude', sa.Float, nullable=True), 29 | sa.Column('longitude', sa.Float, nullable=True), 30 | sa.Column('created', sa.DateTime(timezone=True), nullable=False), 31 | sa.Column('updated', sa.DateTime(timezone=True), nullable=False), 32 | ) 33 | op.add_column('devices', 34 | sa.Column('devnonce', sa.dialects.postgresql.ARRAY(sa.Integer()))) 35 | 36 | 37 | def downgrade(): 38 | op.drop_table('gateways') 39 | op.drop_column('devices', 'devnonce') -------------------------------------------------------------------------------- /floranet/data/alembic/versions/09a18d3d3b1e_create_appif_azureiotmqtt_table.py: -------------------------------------------------------------------------------- 1 | """create azure iot mqtt table 2 | 3 | Revision ID: 09a18d3d3b1e 4 | Revises: d56db793263d 5 | Create Date: 2017-12-05 13:30:03.219377 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '09a18d3d3b1e' 14 | down_revision = 'd56db793263d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | op.create_table( 20 | 'appif_azure_iot_mqtt', 21 | sa.Column('id', sa.Integer, primary_key=True), 22 | sa.Column('name', sa.String, nullable=False, unique=True), 23 | sa.Column('iothost', sa.String, nullable=False), 24 | sa.Column('keyname', sa.String, nullable=False), 25 | sa.Column('keyvalue', sa.String, nullable=False), 26 | sa.Column('created', sa.DateTime(timezone=True), nullable=False), 27 | sa.Column('updated', sa.DateTime(timezone=True), nullable=False), 28 | ) 29 | 30 | def downgrade(): 31 | op.drop_table('appif_azure_iot_mqtt') 32 | 33 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/12d5dcd9a231_add_device_adr_flag.py: -------------------------------------------------------------------------------- 1 | """Add device ADR flag 2 | 3 | Revision ID: 12d5dcd9a231 4 | Revises: 996580ba7f6c 5 | Create Date: 2017-08-12 13:11:07.474645 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '12d5dcd9a231' 14 | down_revision = '996580ba7f6c' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | op.add_column('devices', 20 | sa.Column('adr', sa.Boolean(), nullable=False, server_default=sa.true())) 21 | 22 | 23 | def downgrade(): 24 | op.drop_column('devices', 'adr') 25 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/282e6b269222_create_device_name_lat_long_enabled_and_otaa_.py: -------------------------------------------------------------------------------- 1 | """create device name lat long enabled and otaa columns 2 | 3 | Revision ID: 282e6b269222 4 | Revises: 03fabc9f542b 5 | Create Date: 2017-02-04 17:28:33.947208 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '282e6b269222' 14 | down_revision = '03fabc9f542b' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | op.add_column('devices', 20 | sa.Column('name', sa.String(), nullable=False, server_default='device')) 21 | op.add_column('devices', 22 | sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true())) 23 | op.add_column('devices', 24 | sa.Column('otaa', sa.Boolean(), nullable=False, server_default=sa.true())) 25 | op.add_column('devices', 26 | sa.Column('latitude', sa.Float(), nullable=True)) 27 | op.add_column('devices', 28 | sa.Column('longitude', sa.Float(), nullable=True)) 29 | 30 | 31 | def downgrade(): 32 | op.drop_column('devices', 'name') 33 | op.drop_column('devices', 'enabled') 34 | op.drop_column('devices', 'otaa') 35 | op.drop_column('devices', 'latitude') 36 | op.drop_column('devices', 'longitude') 37 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/56e7e493cad7_add_device_snr_array_column.py: -------------------------------------------------------------------------------- 1 | """Add device snr array column 2 | 3 | Revision ID: 56e7e493cad7 4 | Revises: 99f8aa50ac47 5 | Create Date: 2017-03-01 20:00:55.501494 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '56e7e493cad7' 13 | down_revision = '99f8aa50ac47' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | # Replace multiple snr columns with a snr array column 18 | def upgrade(): 19 | op.drop_column('devices', 'snr1') 20 | op.drop_column('devices', 'snr2') 21 | op.drop_column('devices', 'snr3') 22 | op.drop_column('devices', 'snr4') 23 | op.drop_column('devices', 'snr5') 24 | op.drop_column('devices', 'snr6') 25 | op.drop_column('devices', 'snr7') 26 | op.drop_column('devices', 'snr8') 27 | op.drop_column('devices', 'snr9') 28 | op.drop_column('devices', 'snr10') 29 | op.drop_column('devices', 'snr11') 30 | op.add_column('devices', 31 | sa.Column('snr', sa.dialects.postgresql.ARRAY(sa.Float()))) 32 | 33 | def downgrade(): 34 | op.add_column('devices', 35 | sa.Column('snr1', sa.Float(), nullable=True)) 36 | op.add_column('devices', 37 | sa.Column('snr2', sa.Float(), nullable=True)) 38 | op.add_column('devices', 39 | sa.Column('snr3', sa.Float(), nullable=True)) 40 | op.add_column('devices', 41 | sa.Column('snr4', sa.Float(), nullable=True)) 42 | op.add_column('devices', 43 | sa.Column('snr5', sa.Float(), nullable=True)) 44 | op.add_column('devices', 45 | sa.Column('snr6', sa.Float(), nullable=True)) 46 | op.add_column('devices', 47 | sa.Column('snr7', sa.Float(), nullable=True)) 48 | op.add_column('devices', 49 | sa.Column('snr8', sa.Float(), nullable=True)) 50 | op.add_column('devices', 51 | sa.Column('snr9', sa.Float(), nullable=True)) 52 | op.add_column('devices', 53 | sa.Column('snr10', sa.Float(), nullable=True)) 54 | op.add_column('devices', 55 | sa.Column('snr11', sa.Float(), nullable=True)) 56 | op.drop_column('devices', 'snr') 57 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/5f0ed1bab7fa_create_config_table.py: -------------------------------------------------------------------------------- 1 | """create config table 2 | 3 | Revision ID: 5f0ed1bab7fa 4 | Revises: f966d7f314d5 5 | Create Date: 2017-05-16 16:04:05.229611 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects.postgresql import INET 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '5f0ed1bab7fa' 14 | down_revision = 'f966d7f314d5' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | op.create_table( 20 | 'config', 21 | sa.Column('id', sa.Integer, primary_key=True), 22 | sa.Column('name', sa.String, nullable=False), 23 | sa.Column('listen', INET, nullable=False), 24 | sa.Column('port', sa.Integer, nullable=False), 25 | sa.Column('webport', sa.Integer, nullable=False), 26 | sa.Column('apitoken', sa.String, nullable=False), 27 | sa.Column('freqband', sa.String, nullable=False), 28 | sa.Column('netid', sa.Integer, nullable=False), 29 | sa.Column('duplicateperiod', sa.Integer, nullable=False), 30 | sa.Column('fcrelaxed', sa.Boolean, nullable=False), 31 | sa.Column('otaastart', sa.Integer, nullable=False), 32 | sa.Column('otaaend', sa.Integer, nullable=False), 33 | sa.Column('macqueueing', sa.Boolean, nullable=False), 34 | sa.Column('macqueuelimit', sa.Integer, nullable=False), 35 | sa.Column('adrenable', sa.Boolean, nullable=False), 36 | sa.Column('adrmargin', sa.Float, nullable=False), 37 | sa.Column('adrcycletime', sa.Integer, nullable=False), 38 | sa.Column('adrmessagetime', sa.Integer, nullable=False), 39 | sa.Column('created', sa.DateTime(timezone=True), nullable=False), 40 | sa.Column('updated', sa.DateTime(timezone=True), nullable=False), 41 | ) 42 | 43 | def downgrade(): 44 | op.drop_table('config') 45 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/66bc8df33d36create_application_interfaces_relation.py: -------------------------------------------------------------------------------- 1 | """create application app interfaces relationship 2 | 3 | Revision ID: 66bc8df33d36 4 | Revises: bdf0f3bcffc7 5 | Create Date: 2017-07-08 18:28:26.093817 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '66bc8df33d36' 13 | down_revision = 'bdf0f3bcffc7' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | # Create a polymorphic relationship between Applicaiton and 18 | # the relevant application interface classe. The columns 19 | # interfaceclass_id and interfaceclass_type are used to identify 20 | # the id and type of the other class. 21 | 22 | # Remove columns here that should be owned by the applicaiton 23 | # interface class 24 | 25 | def upgrade(): 26 | op.create_table( 27 | 'appinterfaces', 28 | sa.Column('id', sa.Integer, primary_key=True), 29 | sa.Column('interfaces_id', sa.Integer), 30 | sa.Column('interfaces_type', sa.String), 31 | ) 32 | op.drop_column('applications', 'modname') 33 | op.drop_column('applications', 'proto') 34 | op.drop_column('applications', 'listen') 35 | op.drop_column('applications', 'port') 36 | op.add_column('applications', 37 | sa.Column('appinterface_id', sa.Integer, nullable=True)) 38 | 39 | def downgrade(): 40 | op.drop_table('appinterfaces') 41 | op.drop_column('applications', 'appinterface_id') 42 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/996580ba7f6c_add_device_appname_column.py: -------------------------------------------------------------------------------- 1 | """add device appname column 2 | 3 | Revision ID: 996580ba7f6c 4 | Revises: 66bc8df33d36 5 | Create Date: 2017-07-12 16:19:12.192935 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '996580ba7f6c' 14 | down_revision = '66bc8df33d36' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | op.add_column('devices', 20 | sa.Column('appname', sa.String(), nullable=True)) 21 | 22 | 23 | def downgrade(): 24 | op.drop_column('devices', 'appname') 25 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/99f8aa50ac47_otaa_devices_to_be_pre_provisioned_in_.py: -------------------------------------------------------------------------------- 1 | """OTAA devices to be pre-provisioned in the database. 2 | Make columns devaddr nwkskey appskey nullable; deveui non-nullable. 3 | 4 | Revision ID: 99f8aa50ac47 5 | Revises: 282e6b269222 6 | Create Date: 2017-02-20 10:01:14.549853 7 | 8 | """ 9 | from alembic import op 10 | from sqlalchemy.sql import table, column 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = '99f8aa50ac47' 16 | down_revision = '282e6b269222' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | def upgrade(): 21 | op.alter_column('devices', 'devaddr', nullable=True) 22 | op.alter_column('devices', 'nwkskey', nullable=True) 23 | op.alter_column('devices', 'appskey', nullable=True) 24 | op.alter_column('devices', 'deveui', nullable=False) 25 | 26 | def update_null_values(c): 27 | t = table('devices', column(c)) 28 | op.execute(t.update().values(**{c: 0})) 29 | 30 | def downgrade(): 31 | update_null_values('devaddr') 32 | op.alter_column('devices', 'devaddr', nullable=False) 33 | update_null_values('nwkskey') 34 | op.alter_column('devices', 'nwkskey', nullable=False) 35 | update_null_values('appskey') 36 | op.alter_column('devices', 'appskey', nullable=False) 37 | op.alter_column('devices', 'deveui', nullable=True) 38 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/ad38a9fad16b_add_device_adr_creation_and_update_columns.py: -------------------------------------------------------------------------------- 1 | """Add device ADR tracking, creation, update and fcnterror columns 2 | 3 | Revision ID: ad38a9fad16b 4 | Revises: e7ff8a1b22fd 5 | Create Date: 2016-12-15 22:25:48.605782 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = 'ad38a9fad16b' 13 | down_revision = 'e7ff8a1b22fd' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | def upgrade(): 18 | op.add_column('devices', 19 | sa.Column('adr_datr', sa.String(), nullable=True)) 20 | op.add_column('devices', 21 | sa.Column('snr_pointer', sa.Integer(), nullable=True)) 22 | op.add_column('devices', 23 | sa.Column('snr_average', sa.Float(), nullable=True)) 24 | op.add_column('devices', 25 | sa.Column('snr1', sa.Float(), nullable=True)) 26 | op.add_column('devices', 27 | sa.Column('snr2', sa.Float(), nullable=True)) 28 | op.add_column('devices', 29 | sa.Column('snr3', sa.Float(), nullable=True)) 30 | op.add_column('devices', 31 | sa.Column('snr4', sa.Float(), nullable=True)) 32 | op.add_column('devices', 33 | sa.Column('snr5', sa.Float(), nullable=True)) 34 | op.add_column('devices', 35 | sa.Column('snr6', sa.Float(), nullable=True)) 36 | op.add_column('devices', 37 | sa.Column('snr7', sa.Float(), nullable=True)) 38 | op.add_column('devices', 39 | sa.Column('snr8', sa.Float(), nullable=True)) 40 | op.add_column('devices', 41 | sa.Column('snr9', sa.Float(), nullable=True)) 42 | op.add_column('devices', 43 | sa.Column('snr10', sa.Float(), nullable=True)) 44 | op.add_column('devices', 45 | sa.Column('snr11', sa.Float(), nullable=True)) 46 | op.add_column('devices', 47 | sa.Column('fcnterror', sa.Boolean(), nullable=False, default=False)) 48 | op.add_column('devices', 49 | sa.Column('created', sa.DateTime(timezone=True))) 50 | op.add_column('devices', 51 | sa.Column('updated', sa.DateTime(timezone=True))) 52 | 53 | def downgrade(): 54 | op.drop_column('devices', 'adr_datr') 55 | op.drop_column('devices', 'snr_pointer') 56 | op.drop_column('devices', 'snr_average') 57 | op.drop_column('devices', 'snr1') 58 | op.drop_column('devices', 'snr2') 59 | op.drop_column('devices', 'snr3') 60 | op.drop_column('devices', 'snr4') 61 | op.drop_column('devices', 'snr5') 62 | op.drop_column('devices', 'snr6') 63 | op.drop_column('devices', 'snr7') 64 | op.drop_column('devices', 'snr8') 65 | op.drop_column('devices', 'snr9') 66 | op.drop_column('devices', 'snr10') 67 | op.drop_column('devices', 'snr11') 68 | op.drop_column('devices', 'fcnterror') 69 | op.drop_column('devices', 'created') 70 | op.drop_column('devices', 'updated') 71 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/b664cccf21a2_device_deveui_unique_constraint.py: -------------------------------------------------------------------------------- 1 | """Change device deveui column unique 2 | 3 | Revision ID: b664cccf21a2 4 | Revises: ad38a9fad16b 5 | Create Date: 2016-12-16 13:41:18.297672 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = 'b664cccf21a2' 13 | down_revision = 'ad38a9fad16b' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | def upgrade(): 18 | # Applies a unique constraint on the devices table for the column deveui 19 | op.create_index('devices_deveui_unique', 'devices', ['deveui'], unique=True) 20 | 21 | def downgrade(): 22 | op.drop_index('devices_deveui_unique') 23 | 24 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/bdf0f3bcffc7_create_appif_reflector_azureiothttps.py: -------------------------------------------------------------------------------- 1 | """create appif reflector azure_iot_https 2 | 3 | Revision ID: bdf0f3bcffc7 4 | Revises: 5f0ed1bab7fa 5 | Create Date: 2017-07-08 14:21:54.736175 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects.postgresql import INET 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'bdf0f3bcffc7' 14 | down_revision = '5f0ed1bab7fa' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | op.create_table( 20 | 'appif_reflector', 21 | sa.Column('id', sa.Integer, primary_key=True), 22 | sa.Column('name', sa.String, nullable=False, unique=True), 23 | sa.Column('created', sa.DateTime(timezone=True), nullable=False), 24 | sa.Column('updated', sa.DateTime(timezone=True), nullable=False), 25 | ) 26 | op.create_table( 27 | 'appif_azure_iot_https', 28 | sa.Column('id', sa.Integer, primary_key=True), 29 | sa.Column('name', sa.String, nullable=False, unique=True), 30 | sa.Column('iothost', sa.String, nullable=False), 31 | sa.Column('keyname', sa.String, nullable=False), 32 | sa.Column('keyvalue', sa.String, nullable=False), 33 | sa.Column('poll_interval', sa.Integer, nullable=False), 34 | sa.Column('created', sa.DateTime(timezone=True), nullable=False), 35 | sa.Column('updated', sa.DateTime(timezone=True), nullable=False), 36 | ) 37 | 38 | def downgrade(): 39 | op.drop_table('appif_azure_iot_https') 40 | op.drop_table('appif_reflector') 41 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/d56db793263d_create_app_properties_table.py: -------------------------------------------------------------------------------- 1 | """Add data_properties table 2 | 3 | Revision ID: d56db793263d 4 | Revises: 12d5dcd9a231 5 | Create Date: 2017-08-20 14:42:53.285592 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'd56db793263d' 14 | down_revision = '12d5dcd9a231' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | op.create_table( 20 | 'app_properties', 21 | sa.Column('id', sa.Integer, primary_key=True), 22 | sa.Column('application_id', sa.Integer()), 23 | sa.Column('port', sa.Integer(), nullable=False), 24 | sa.Column('name', sa.String(), nullable=False), 25 | sa.Column('type', sa.String(), nullable=False), 26 | sa.Column('created', sa.DateTime(timezone=True), nullable=False), 27 | sa.Column('updated', sa.DateTime(timezone=True), nullable=False), 28 | ) 29 | 30 | def downgrade(): 31 | op.drop_table('app_properties') 32 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/d5ed30f62f76_create_appif_file_text_store.py: -------------------------------------------------------------------------------- 1 | """create appif file text store 2 | 3 | Revision ID: d5ed30f62f76 4 | Revises: 09a18d3d3b1e 5 | Create Date: 2018-07-30 18:47:12.417385 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'd5ed30f62f76' 14 | down_revision = '09a18d3d3b1e' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | op.create_table( 20 | 'appif_file_text_store', 21 | sa.Column('id', sa.Integer, primary_key=True), 22 | sa.Column('name', sa.String, nullable=False, unique=True), 23 | sa.Column('file', sa.String, nullable=False, unique=True), 24 | sa.Column('created', sa.DateTime(timezone=True), nullable=False), 25 | sa.Column('updated', sa.DateTime(timezone=True), nullable=False), 26 | ) 27 | 28 | def downgrade(): 29 | op.drop_table('appif_file_text_store') 30 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/e7ff8a1b22fd_create_device_table.py: -------------------------------------------------------------------------------- 1 | """Create device table 2 | 3 | Revision ID: e7ff8a1b22fd 4 | Revises: 5 | Create Date: 2016-10-10 19:32:50.611470 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = 'e7ff8a1b22fd' 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | def upgrade(): 18 | op.create_table( 19 | 'devices', 20 | sa.Column('id', sa.Integer, primary_key=True), 21 | sa.Column('deveui', sa.Numeric, nullable=True), 22 | sa.Column('devaddr', sa.Integer, nullable=False), 23 | sa.Column('devclass', sa.String(length=1), nullable=False), 24 | sa.Column('appeui', sa.Numeric, nullable=False), 25 | sa.Column('nwkskey', sa.Numeric, nullable=False), 26 | sa.Column('appskey', sa.Numeric, nullable=False), 27 | sa.Column('tx_chan', sa.Integer, nullable=True), 28 | sa.Column('tx_datr', sa.String, nullable=True), 29 | sa.Column('gw_addr', sa.String, nullable=True), 30 | sa.Column('time', sa.DateTime(timezone=True), nullable=True), 31 | sa.Column('tmst', sa.Numeric, nullable=True), 32 | sa.Column('fcntup', sa.Integer, server_default="0", nullable=False), 33 | sa.Column('fcntdown', sa.Integer, server_default="0", nullable=False), 34 | ) 35 | 36 | def downgrade(): 37 | op.drop_table('devices') 38 | -------------------------------------------------------------------------------- /floranet/data/alembic/versions/f966d7f314d5_create_application_table.py: -------------------------------------------------------------------------------- 1 | """create application table 2 | 3 | Revision ID: f966d7f314d5 4 | Revises: 56e7e493cad7 5 | Create Date: 2017-03-20 13:27:04.791521 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = 'f966d7f314d5' 13 | down_revision = '56e7e493cad7' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | def upgrade(): 18 | op.create_table( 19 | 'applications', 20 | sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), 21 | sa.Column('appeui', sa.Numeric, nullable=False, unique=True), 22 | sa.Column('name', sa.String, nullable=True), 23 | sa.Column('domain', sa.String, nullable=True), 24 | sa.Column('appnonce', sa.Integer, nullable=False), 25 | sa.Column('appkey', sa.Numeric, nullable=False), 26 | sa.Column('fport', sa.Integer, nullable=False), 27 | sa.Column('modname', sa.String, nullable=False), 28 | sa.Column('proto', sa.String, nullable=False), 29 | sa.Column('listen', sa.String, nullable=False), 30 | sa.Column('port', sa.Integer, nullable=False), 31 | sa.Column('created', sa.DateTime(timezone=True), nullable=False), 32 | sa.Column('updated', sa.DateTime(timezone=True), nullable=False), 33 | ) 34 | 35 | def downgrade(): 36 | op.drop_table('applications') 37 | -------------------------------------------------------------------------------- /floranet/data/seed/applications.csv: -------------------------------------------------------------------------------- 1 | name,domain,appeui,appnonce,appkey,fport,appinterface_id 2 | Test,fluentnetworks.com.au,0x0A0B0C0D0A0B0C0D,0xC28AE9,0x017E151638AEC2A6ABF7258809CF4F3C,15,1 -------------------------------------------------------------------------------- /floranet/data/seed/applications.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import csv 3 | import datetime 4 | import pytz 5 | from sqlalchemy import Column, Integer, Numeric, String, Boolean, DateTime 6 | from sqlalchemy.ext.declarative import declarative_base 7 | 8 | Base = declarative_base() 9 | 10 | class Application(Base): 11 | __tablename__ = 'applications' 12 | id = Column(Integer, primary_key=True, autoincrement=True) 13 | appeui = Column(Numeric, nullable=False, unique=True) 14 | name = Column(String, nullable=False) 15 | domain = Column(String, nullable=False) 16 | appkey = Column(Numeric, nullable=False) 17 | fport = Column(Integer, nullable=False) 18 | appnonce = Column(Integer, nullable=False) 19 | appinterface_id = Column(Integer, nullable=True) 20 | created = Column(DateTime(timezone=True), nullable=False) 21 | updated = Column(DateTime(timezone=True), nullable=False) 22 | 23 | @classmethod 24 | def seed(cls, session): 25 | apps = [] 26 | # Read fields from the CSV file 27 | with open('applications.csv') as sfile: 28 | reader = csv.DictReader(sfile) 29 | for line in reader: 30 | # Convert data 31 | a = {} 32 | for k,v in line.iteritems(): 33 | if k in {'name', 'domain'}: 34 | a[k] = v 35 | continue 36 | else: 37 | a[k] = ast.literal_eval(v) if v else '' 38 | apps.append(a) 39 | # Set timestamps as UTC 40 | for a in apps: 41 | now = datetime.datetime.now(tz=pytz.utc).isoformat() 42 | a['created'] = now 43 | a['updated'] = now 44 | # Insert rows 45 | session.bulk_insert_mappings(Application, apps) 46 | 47 | @classmethod 48 | def clear (cls, session): 49 | apps = session.query(Application).all() 50 | for a in apps: 51 | session.delete(a) 52 | -------------------------------------------------------------------------------- /floranet/data/seed/devices.csv: -------------------------------------------------------------------------------- 1 | name,devclass,otaa,deveui,appeui,devaddr,nwkskey,appskey 2 | abp_device_1,a,False,0x0F0E0E0D00000001,0x0A0B0C0D0A0B0C0D,0x06100000,0xAEB48D4C6E9EA5C48C37E4F132AA8516,0x7987A96F267F0A86B739EED480FC2B3C 3 | abp_device_2,a,False,0x0F0E0E0D00000002,0x0A0B0C0D0A0B0C0D,0x06100001,0x8D952A0140C298C010F418FF70EFC2B2,0xF00E22163B9E9260B454BE6C4B26A91C 4 | abp_device_3,a,False,0x0F0E0E0D00000003,0x0A0B0C0D0A0B0C0D,0x06100002,0x9E583C0B4B64789A81E6A39249D478C8,0x85B010B651C834D3583B9F907BEF4945 5 | ota_device_1,a,True,0x0F0E0E0D00010203,0x0A0B0C0D0A0B0C0D,,, -------------------------------------------------------------------------------- /floranet/data/seed/devices.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import csv 3 | import datetime 4 | import pytz 5 | from sqlalchemy import Column, Integer, Numeric, String, Boolean, DateTime 6 | from sqlalchemy.ext.declarative import declarative_base 7 | 8 | Base = declarative_base() 9 | 10 | class Device(Base): 11 | __tablename__ = 'devices' 12 | id = Column(Integer, primary_key=True, autoincrement=True) 13 | name = Column(String, nullable=False) 14 | otaa = Column(Boolean, nullable=False) 15 | deveui = Column(Numeric, nullable=False, unique=True) 16 | devclass = Column(String, nullable=False) 17 | devaddr = Column(Integer, nullable=False) 18 | appeui = Column(Numeric, nullable=False) 19 | nwkskey = Column(Numeric, nullable=False) 20 | appskey = Column(Numeric, nullable=False) 21 | tx_chan = Column(Integer, nullable=True, default=1) 22 | tx_datr = Column(String, nullable=True) 23 | gw_addr = Column(String, nullable=True) 24 | fcntup = Column(Integer, nullable=False, default=0) 25 | fcntdown = Column(Integer, nullable=False, default=0) 26 | fcnterror = Column(Boolean, nullable=False, default=False) 27 | created = Column(DateTime(timezone=True), nullable=False) 28 | updated = Column(DateTime(timezone=True), nullable=False) 29 | 30 | @classmethod 31 | def seed(cls, session): 32 | devices = [] 33 | # Read fields from the CSV file 34 | with open('devices.csv') as sfile: 35 | reader = csv.DictReader(sfile) 36 | for line in reader: 37 | # Convert data 38 | d = {} 39 | for k,v in line.iteritems(): 40 | if k in {'name', 'devclass'}: 41 | d[k] = v 42 | continue 43 | elif k in {'devaddr', 'nwkskey', 'appskey'} and v == '': 44 | d[k] = None 45 | continue 46 | else: 47 | d[k] = ast.literal_eval(v) if v else '' 48 | devices.append(d) 49 | # Set timestamps as UTC 50 | for d in devices: 51 | now = datetime.datetime.now(tz=pytz.utc).isoformat() 52 | d['created'] = now 53 | d['updated'] = now 54 | # Insert rows 55 | session.bulk_insert_mappings(Device, devices) 56 | 57 | @classmethod 58 | def clear (cls, session): 59 | devices = session.query(Device).all() 60 | for d in devices: 61 | session.delete(d) 62 | -------------------------------------------------------------------------------- /floranet/data/seed/gateways.csv: -------------------------------------------------------------------------------- 1 | host,eui,name,power 2 | 192.168.1.125,3.60288E+16,Test,26 -------------------------------------------------------------------------------- /floranet/data/seed/gateways.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import csv 3 | import datetime 4 | import pytz 5 | from sqlalchemy import Column, Integer, String, Boolean, DateTime 6 | from sqlalchemy.dialects.postgresql import INET 7 | from sqlalchemy.ext.declarative import declarative_base 8 | 9 | Base = declarative_base() 10 | 11 | class Gateway(Base): 12 | __tablename__ = 'gateways' 13 | id = Column(Integer, primary_key=True, autoincrement=True) 14 | host = Column(INET, nullable=False, unique=True) 15 | name = Column(String, nullable=True) 16 | enabled = Column(Boolean, nullable=False, default=True) 17 | eui = Column(Integer, nullable=True) 18 | power = Column(Integer, nullable=False) 19 | port = Column(Integer, nullable=True) 20 | created = Column(DateTime(timezone=True), nullable=False) 21 | updated = Column(DateTime(timezone=True), nullable=False) 22 | 23 | @classmethod 24 | def seed(cls, session): 25 | gateways = [] 26 | # Read fields from the CSV file 27 | with open('gateways.csv') as sfile: 28 | reader = csv.DictReader(sfile) 29 | for line in reader: 30 | # Convert data using literal_eval 31 | g = {} 32 | for k,v in line.iteritems(): 33 | g[k] = v 34 | gateways.append(g) 35 | # Set timestamps as UTC 36 | for g in gateways: 37 | now = datetime.datetime.now(tz=pytz.utc).isoformat() 38 | g['created'] = now 39 | g['updated'] = now 40 | # Insert rows 41 | session.bulk_insert_mappings(Gateway, gateways) 42 | 43 | @classmethod 44 | def clear (cls, session): 45 | gateways = session.query(Gateway).all() 46 | for g in gateways: 47 | session.delete(g) 48 | -------------------------------------------------------------------------------- /floranet/data/seed/seeder: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.orm import sessionmaker 7 | 8 | from devices import Device 9 | from gateways import Gateway 10 | from applications import Application 11 | 12 | # Create the enginer 13 | engine = create_engine('postgresql://postgres:postgres@localhost/floranet') 14 | 15 | # Create the session 16 | Session = sessionmaker(bind=engine) 17 | session = Session() 18 | 19 | classes = (Device, Gateway, Application) 20 | 21 | def seed(): 22 | print "Seeding the database..." 23 | try: 24 | for c in classes: 25 | c.seed(session) 26 | session.commit() 27 | except: 28 | session.rollback() 29 | raise 30 | finally: 31 | session.close() 32 | 33 | def clear(): 34 | print "Clearing the database..." 35 | try: 36 | for c in classes: 37 | c.clear(session) 38 | session.commit() 39 | except: 40 | session.rollback() 41 | raise 42 | finally: 43 | session.close() 44 | 45 | def parseCommandLine(): 46 | """Parse command line arguments""" 47 | 48 | parser = argparse.ArgumentParser(description='Seed data with SQLAlchemy.') 49 | parser.add_argument('-s', dest='seed', action='store_true', 50 | help='seed the database') 51 | parser.add_argument('-c', dest='clear', action='store_true', 52 | help='clear seed data') 53 | parser.add_argument('-r', dest='reseed', action='store_true', 54 | help='clear and seed the database') 55 | return parser.parse_args() 56 | 57 | def main(): 58 | # Parse command line arguments 59 | args = parseCommandLine() 60 | 61 | if args.seed: 62 | seed() 63 | elif args.clear: 64 | clear() 65 | elif args.reseed: 66 | clear() 67 | seed() 68 | 69 | if __name__ == '__main__': 70 | raise SystemExit(main()) -------------------------------------------------------------------------------- /floranet/database.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ipaddress 3 | import ConfigParser 4 | 5 | import psycopg2 6 | from twisted.enterprise import adbapi 7 | from twistar.registry import Registry 8 | 9 | from floranet.models.application import Application 10 | from floranet.models.appinterface import AppInterface 11 | from floranet.models.appproperty import AppProperty 12 | from floranet.appserver.reflector import Reflector 13 | from floranet.appserver.azure_iot_https import AzureIotHttps 14 | from floranet.appserver.azure_iot_mqtt import AzureIotMqtt 15 | from floranet.appserver.file_text_store import FileTextStore 16 | from floranet.log import log 17 | 18 | class Option(object): 19 | """Configuration file option 20 | 21 | Model for a configuration option of the form 22 | optionname = optionvalue 23 | 24 | Attributes: 25 | name (str): Name of the option 26 | type: (str): Option data type 27 | default: default value 28 | val: parsed value from configuration file 29 | """ 30 | 31 | def __init__(self, name, type, default=False, val=None, length=0): 32 | """Initialise a configuration object""" 33 | self.name = name 34 | self.type = type 35 | self.default = default 36 | self.val = val 37 | 38 | class Database(object): 39 | """FloraNet database configuration 40 | 41 | Attributes: 42 | parser (SafeConfigParser): parser object 43 | path (str): Path to this module 44 | username (str): 45 | 46 | """ 47 | def __init__(self): 48 | """Initialise configuration. 49 | """ 50 | self.parser = ConfigParser.SafeConfigParser() 51 | self.host = '' 52 | self.user = '' 53 | self.password = '' 54 | self.database = '' 55 | 56 | def test(self): 57 | """Perform a database connection test 58 | 59 | Returns True on success, otherwise False 60 | """ 61 | try: 62 | connection = psycopg2.connect(host=self.host, 63 | user=self.user, password=self.password, 64 | database=self.database) 65 | connection.close() 66 | except psycopg2.OperationalError: 67 | return False 68 | 69 | return True 70 | 71 | def start(self): 72 | """Create the ADBAPI connection pool. 73 | 74 | """ 75 | Registry.DBPOOL = adbapi.ConnectionPool('psycopg2', host=self.host, 76 | user=self.user, password=self.password, 77 | database=self.database) 78 | 79 | def register(self): 80 | """Register class relationships 81 | 82 | """ 83 | # Application, AppInterface and AppProperty 84 | Registry.register(Application, AppInterface, AppProperty) 85 | # AppInterface and the concrete classes 86 | Registry.register(Reflector, FileTextStore, AzureIotHttps, AzureIotMqtt, 87 | AppInterface) 88 | 89 | def _getOption(self, section, option, obj): 90 | """Parse options for the section 91 | 92 | Args: 93 | section (str): the section to check 94 | option (Option): option to parse 95 | obj: Object to set the attribute of (name) with value 96 | """ 97 | 98 | # Check option exists 99 | if not self.parser.has_option(section, option.name): 100 | log.error("Could not find option {opt} in {section}", 101 | opt=option.name, section=section) 102 | return False 103 | 104 | # Check it can be accessed 105 | try: 106 | v = self.parser.get(section, option.name) 107 | except ConfigParser.Error: 108 | log.error("Could not parse option {opt} in {section}", 109 | opt=option.name, section=section) 110 | return False 111 | 112 | # Set default value if required 113 | if v == '' and option.default: 114 | setattr(obj, option.name, option.val) 115 | return True 116 | 117 | # String type. No checks required. 118 | if option.type == 'str': 119 | pass 120 | 121 | # Check boolean type 122 | elif option.type == 'boolean': 123 | try: 124 | v = self.parser.getboolean(section, option.name) 125 | except (ConfigParser.Error, ValueError): 126 | log.error("Could not parse option {opt} in {section}", 127 | opt=option.name, section=section) 128 | return False 129 | 130 | # Check integer type 131 | elif option.type == 'int': 132 | try: 133 | v = int(self.parser.getint(section, option.name)) 134 | except (ConfigParser.Error, ValueError): 135 | log.error("Could not parse option {opt} in {section}", 136 | opt=option.name, section=section) 137 | return False 138 | 139 | # Check address type 140 | elif option.type == 'address': 141 | try: 142 | ipaddress.ip_address(v) 143 | except (ipaddress.AddressValueError, ValueError): 144 | log.error("Could not parse option {opt} in {section}: " 145 | "invalid address {address}", address=v) 146 | return False 147 | 148 | # Check hex type 149 | elif option.type == 'hex': 150 | if len(v) / 2 != option.len + 1: 151 | log.error("Option {opt} in {section} is incorrect length: " 152 | "hex value should be {n} octets", 153 | opt=option.name, section=section, n=option.len) 154 | return False 155 | try: 156 | v = int(v, 16) 157 | except ValueError: 158 | log.error("Could not parse option {opt} in {section}: " 159 | "invalid hex value {value}", opt=option.name, 160 | section=section, value=v) 161 | return False 162 | 163 | # Check array type 164 | elif option.type == 'array': 165 | try: 166 | v = eval(v) 167 | except (NameError, SyntaxError, ValueError): 168 | log.error("Could not parse array {opt} in {section}", 169 | opt=option.name, section=section) 170 | return False 171 | if not isinstance(v, list): 172 | log.error("Error parsing array {opt} in {section}", 173 | opt=option.name, section=section) 174 | return False 175 | 176 | setattr(obj, option.name, v) 177 | return True 178 | 179 | def parseConfig(self, cfile): 180 | """Parse the database configuration file 181 | 182 | Args: 183 | cfile (str): Configuration file path 184 | 185 | Returns: 186 | True on success, otherwise False 187 | """ 188 | # Check file exists 189 | if not os.path.exists(cfile): 190 | log.error("Can't find database configuration file {cfile}", 191 | cfile=cfile) 192 | return False 193 | elif not os.path.isfile(cfile): 194 | log.error("Can't read database configuration file {cfile}", 195 | cfile=cfile) 196 | return False 197 | 198 | try: 199 | self.parser.read(cfile) 200 | except ConfigParser.ParsingError: 201 | log.error("Error parsing configuration file {cfile}", 202 | cfile=cfile) 203 | return False 204 | 205 | # Get sections 206 | sections = self.parser.sections() 207 | 208 | # Database section 209 | if 'database' not in sections: 210 | log.error("Couldn't find the [database] section in the configuration file") 211 | return False 212 | options = [ 213 | Option('host', 'str', default=False), 214 | Option('user', 'str', default=False), 215 | Option('password', 'str', default=False), 216 | Option('database', 'str', default=False), 217 | ] 218 | for option in options: 219 | if not self._getOption('database', option, self): 220 | return False 221 | 222 | return True 223 | 224 | -------------------------------------------------------------------------------- /floranet/error.py: -------------------------------------------------------------------------------- 1 | 2 | class Error(Exception): 3 | """ 4 | Base exception for all exceptions that indicate a failed request 5 | """ 6 | 7 | class DecodeError(Error): 8 | """" 9 | Raised when the request cannot be decoded by the server. 10 | """ 11 | 12 | class NoFreeOTAAddresses(Error): 13 | """" 14 | Raised when the OTA request cannot be completed due to no free addresses. 15 | """ 16 | 17 | class UnsupportedMethod(Error): 18 | """ 19 | Raised when request method is not understood by the server at all. 20 | """ 21 | 22 | class NotImplemented(Error): 23 | """ 24 | Raised when request is correct, but feature is not implemented 25 | by the server. 26 | """ 27 | 28 | class RequestTimedOut(Error): 29 | """ 30 | Raised when request is timed out. 31 | """ 32 | 33 | class WaitingForClientTimedOut(Error): 34 | """ 35 | Raised when server expects some client action but the client does nothing. 36 | """ 37 | 38 | __all__ = ['Error', 39 | 'UnsupportedMethod', 40 | 'NotImplemented', 41 | 'RequestTimedOut', 42 | 'WaitingForClientTimedOut'] 43 | -------------------------------------------------------------------------------- /floranet/imanager.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.defer import inlineCallbacks, returnValue 2 | 3 | from floranet.models.appinterface import AppInterface 4 | from floranet.models.application import Application 5 | from floranet.log import log 6 | 7 | class InterfaceManager(object): 8 | """Manages the server's application interfaces 9 | 10 | Attribues: 11 | interfaces (list): List of interfaces 12 | netserver (NetServer): The network server 13 | """ 14 | 15 | def __init__(self): 16 | self.interfaces = [] 17 | self.netserver = None 18 | 19 | @inlineCallbacks 20 | def start(self, netserver): 21 | """Load all application interfaces and start them. 22 | 23 | Args: 24 | netserver (NetServer): The network server 25 | """ 26 | 27 | self.netserver = netserver 28 | 29 | # Get all concrete application interface objects 30 | appinterfaces = yield AppInterface.all() 31 | for appinterface in appinterfaces: 32 | # Get the interface, set the appinterface 33 | interface = yield appinterface.interfaces.get() 34 | if interface: 35 | interface.appinterface = appinterface 36 | self.interfaces.append(interface) 37 | 38 | # Start the interfaces 39 | for interface in self.interfaces: 40 | log.info("Starting application interface id {id}: {name}", 41 | id=interface.appinterface.id, name=interface.name) 42 | interface.start(self.netserver) 43 | if not interface.started: 44 | log.error("Could not start application interface " 45 | "id {id}", id=interface.appinterface.id) 46 | 47 | def getInterface(self, appinterface_id): 48 | """Retrieve an interface by application interface id 49 | 50 | Args: 51 | appinterface_id (int): Application interface id 52 | """ 53 | 54 | interface = next((i for i in self.interfaces if 55 | i.appinterface.id == int(appinterface_id)), None) 56 | return(interface) 57 | 58 | def getAllInterfaces(self): 59 | """Retrieve all interfaces""" 60 | 61 | if not self.interfaces: 62 | return None 63 | return self.interfaces 64 | 65 | @inlineCallbacks 66 | def checkInterface(self, appinterface_id): 67 | """Check the referenced interface is required to be active. 68 | 69 | Args: 70 | appinterface_id (int): Application interface id 71 | """ 72 | interface = self.getInterface(appinterface_id) 73 | if interface is None: 74 | returnValue(None) 75 | 76 | # If there are no associated applications, stop the interface. 77 | active = yield Application.find(where=['appinterface_id = ?', appinterface_id]) 78 | #active = yield interface.apps() 79 | if active is None: 80 | interface.stop() 81 | 82 | 83 | @inlineCallbacks 84 | def createInterface(self, interface): 85 | """Add an interface to the interface list" 86 | 87 | Args: 88 | interface: The concrete application interface 89 | 90 | Returns: 91 | Appinterface id on success 92 | """ 93 | 94 | # Create the interface and AppInterface 95 | yield interface.save() 96 | appinterface = AppInterface() 97 | yield appinterface.save() 98 | yield interface.appinterfaces.set([appinterface]) 99 | 100 | # Add the new interface to the list 101 | interface.appinterface = appinterface 102 | self.interfaces.append(interface) 103 | 104 | # Start the interface 105 | interface.start(self.netserver) 106 | if not interface.started: 107 | log.error("Could not start application interface " 108 | "id {id}", interface.appinterface.id) 109 | returnValue(appinterface.id) 110 | yield 111 | 112 | @inlineCallbacks 113 | def updateInterface(self, interface): 114 | """Update an existing interface 115 | 116 | Args: 117 | interface: The concrete application interface 118 | """ 119 | 120 | # Save interface 121 | yield interface.save() 122 | interface.appinterface = yield interface.appinterfaces.get() 123 | 124 | # Retrieve the current running interface and its index 125 | (index, current) = next (((i,iface) for i,iface in 126 | enumerate(self.interfaces) if 127 | iface.appinterface.id == interface.appinterface.id), 128 | (None, None)) 129 | 130 | # Stop and remove the current interface 131 | if current: 132 | current.stop() 133 | del self.interfaces[index] 134 | 135 | # Append the new interface and start 136 | self.interfaces.append(interface) 137 | interface.start(self.netserver) 138 | if not interface.started: 139 | log.error("Could not start application interface " 140 | "id {id}", interface.appinterface.id) 141 | 142 | @inlineCallbacks 143 | def deleteInterface(self, interface): 144 | """Remove an interface from the interface list 145 | 146 | Args: 147 | interface: The concrete application interface 148 | """ 149 | 150 | # Find the interface in the list, and remove 151 | index = next ((i for i,iface in enumerate(self.interfaces) if 152 | iface.appinterface.id == interface.appinterface.id), None) 153 | if index: 154 | del self.interfaces[index] 155 | 156 | # Delete the interface and appinterface records 157 | exists = interface.exists(where=['id = ?', interface.id]) 158 | if exists: 159 | appinterface = yield interface.appinterfaces.get() 160 | yield interface.delete() 161 | yield appinterface[0].delete() 162 | 163 | interfaceManager = InterfaceManager() 164 | 165 | -------------------------------------------------------------------------------- /floranet/log.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import sys 4 | 5 | from twisted.logger import (Logger, LogLevel, LogLevelFilterPredicate, 6 | FilteringLogObserver, 7 | textFileLogObserver, 8 | globalLogBeginner) 9 | 10 | predicate = None 11 | 12 | class Log(Logger): 13 | """Log class 14 | 15 | Netserver logging - subclass of twisted.logger.Logger 16 | """ 17 | 18 | def __init__(self): 19 | """Initialize a Log object.""" 20 | super(Log, self).__init__('Floranet') 21 | 22 | def start(self, console, logfile, debug): 23 | """Configure and start logging based on user preferences 24 | 25 | Args: 26 | console (bool): Console logging enabled 27 | logfile (str): Logfile path 28 | debug (bool): Debugging flag 29 | """ 30 | global predicate 31 | 32 | # Set logging level 33 | level = LogLevel.debug if debug else LogLevel.info 34 | predicate = LogLevelFilterPredicate(defaultLogLevel=level) 35 | 36 | # Log to console option 37 | if console: 38 | f = sys.stdout 39 | 40 | # Log to file option 41 | else: 42 | # Check the file is valid and can be opened in append mode 43 | if os.path.exists(logfile) and not os.path.isfile(logfile): 44 | print "Logfile %s is not a valid file. Exiting." % logfile 45 | return False 46 | try: 47 | f = open(logfile, 'a') 48 | except IOError: 49 | print "Can't open logfile %s. Exiting." % logfile 50 | return False 51 | 52 | # Set the observer 53 | observer = textFileLogObserver(f) 54 | observers = [FilteringLogObserver(observer=observer, 55 | predicates=[predicate])] 56 | # Begin logging 57 | globalLogBeginner.beginLoggingTo(observers) 58 | return True 59 | 60 | log = Log() 61 | -------------------------------------------------------------------------------- /floranet/lora/__init__.py: -------------------------------------------------------------------------------- 1 | #TODO: add some useful description 2 | """Placeholder 3 | """ 4 | pass 5 | 6 | 7 | -------------------------------------------------------------------------------- /floranet/lora/crypto.py: -------------------------------------------------------------------------------- 1 | from CryptoPlus.Cipher import python_AES 2 | 3 | def aesEncrypt(key, data, mode=None): 4 | """AES encryption function 5 | 6 | Args: 7 | key (str): packed 128 bit key 8 | data (str): packed plain text data 9 | mode (str): Optional mode specification (CMAC) 10 | 11 | Returns: 12 | Packed encrypted data string 13 | """ 14 | if mode == 'CMAC': 15 | # Create AES cipher using key argument, and encrypt data 16 | cipher = python_AES.new(key, python_AES.MODE_CMAC) 17 | elif mode == None: 18 | cipher = python_AES.new(key) 19 | return cipher.encrypt(data) 20 | 21 | def aesDecrypt(key, data): 22 | """AES decryption fucnction 23 | 24 | Args: 25 | key (str): packed 128 bit key 26 | data (str): packed encrypted data 27 | 28 | Returns: 29 | Packed decrypted data string 30 | """ 31 | cipher = python_AES.new(key) 32 | return cipher.decrypt(data) 33 | 34 | -------------------------------------------------------------------------------- /floranet/models/__init__.py: -------------------------------------------------------------------------------- 1 | #TODO: add some useful description 2 | """Placeholder 3 | """ 4 | pass 5 | 6 | 7 | -------------------------------------------------------------------------------- /floranet/models/appinterface.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.defer import inlineCallbacks, returnValue 2 | from twistar.registry import Registry 3 | 4 | from model import Model 5 | 6 | class AppInterface(Model): 7 | """Application interface class 8 | 9 | Abstract model representing the application interface. 10 | 11 | Each AppInterface belongs to another concrete interface model using 12 | a polymorphic relationship. 13 | 14 | Attributes: 15 | interfaces_id (int): Application interface class id 16 | interfaces_type (str): Application interface class name 17 | """ 18 | 19 | TABLENAME = 'appinterfaces' 20 | BELONGSTO = [{'name': 'interfaces', 'polymorphic': True}] 21 | 22 | @inlineCallbacks 23 | def apps(self): 24 | """Flags whether this interface has any associated Applications 25 | 26 | """ 27 | apps = yield Application.find(where=['appinterface_id = ?', self.appinterface.id]) 28 | returnValue(apps) 29 | 30 | -------------------------------------------------------------------------------- /floranet/models/application.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.defer import inlineCallbacks, returnValue 2 | 3 | from floranet.models.model import Model 4 | from floranet.models.appinterface import AppInterface 5 | 6 | class Application(Model): 7 | """LoRa application class 8 | 9 | Model representing a LoRa application. 10 | 11 | Attributes: 12 | name (str): a user friendly name for the application 13 | domain (str): optional customer domain string 14 | appeui (int): global application ID (IEEE EUI64) 15 | appnonce (int): A unique ID provided by the network server 16 | appkey (int): AES-128 application secret key 17 | fport (int): Port field used for this application 18 | """ 19 | 20 | TABLENAME = 'applications' 21 | BELONGSTO = [{'name': 'appinterface', 'class_name': 'AppInterface'}] 22 | HASMANY = [{'name': 'properties', 'class_name': 'AppProperty'}] 23 | 24 | @inlineCallbacks 25 | def valid(self): 26 | """Validate an application object. 27 | 28 | Returns: 29 | valid (bool), message(dict): (True, empty) on success, 30 | (False, error message dict) otherwise. 31 | """ 32 | messages = {} 33 | 34 | # Check for unique appkeys 35 | duplicate = yield Application.exists(where=['appkey = ? AND appeui != ?', 36 | self.appkey, self.appeui]) 37 | if duplicate: 38 | messages['appkey'] = "Duplicate application key exists: appkey " \ 39 | "must be unique." 40 | 41 | # Check the app interface exists 42 | if self.appinterface_id: 43 | exists = yield AppInterface.exists(where=['id = ?', self.appinterface_id]) 44 | if not exists: 45 | messages['appinterface_id'] = "Application interface {} does not " \ 46 | "exist.".format(self.appinterface_id) 47 | 48 | valid = not any(messages) 49 | returnValue((valid, messages)) 50 | 51 | -------------------------------------------------------------------------------- /floranet/models/appproperty.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | from twisted.internet.defer import inlineCallbacks, returnValue 4 | 5 | from floranet.models.model import Model 6 | 7 | class AppProperty(Model): 8 | """LoRa application property class 9 | 10 | Model representing a application data properties. A data property 11 | maps the LoRa fport frame payload parameter to a data name, type, 12 | and length. 13 | 14 | Attributes: 15 | application_id (int): application foreign key 16 | port (int): application port 17 | name (str): mapped name for the property 18 | type (str): the data type 19 | """ 20 | 21 | TABLENAME = 'app_properties' 22 | TYPES = {'char': 'c', 23 | 'signed char': 'b', 24 | 'unsigned char': 'B', 25 | 'bool': '?', 26 | 'short': 'h', 27 | 'unsigned short': 'H', 28 | 'int': 'i', 29 | 'unsigned int': 'H', 30 | 'long': 'l', 31 | 'unsigned long': 'L', 32 | 'long long': 'q', 33 | 'unsigned long long': 'Q', 34 | 'float': 'f', 35 | 'double': 'd', 36 | 'char[]': 's' 37 | } 38 | 39 | @inlineCallbacks 40 | def valid(self): 41 | """Validate an application property. 42 | 43 | Returns: 44 | valid (bool), message(dict): (True, empty) on success, 45 | (False, error message dict) otherwise. 46 | """ 47 | messages = {} 48 | 49 | if self.port < 1 or self.port > 223: 50 | messages['port'] = "Invalid port number" 51 | 52 | if self.type not in self.TYPES: 53 | messages['type'] = "Unknown data type" 54 | 55 | valid = not any(messages) 56 | returnValue((valid, messages)) 57 | yield 58 | 59 | def value(self, data): 60 | """Return the value defined by the property from the 61 | application data 62 | 63 | Args: 64 | data (str): application data 65 | """ 66 | 67 | fmt = self.TYPES[self.type] 68 | try: 69 | return struct.unpack(fmt, data)[0] 70 | except struct.error: 71 | return None 72 | -------------------------------------------------------------------------------- /floranet/models/config.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.defer import inlineCallbacks, returnValue 2 | 3 | from model import Model 4 | from floranet.appserver.reflector import Reflector 5 | from floranet.lora.bands import LoraBand 6 | from floranet.util import validIPv4Address, validIPv6Address 7 | 8 | class Config(Model): 9 | """NetServer Configuration model 10 | 11 | Model representing the netserver configuration. 12 | 13 | Attributes: 14 | name(str): Server name 15 | listen (str): Interfaces to listen on 16 | port (int): LoRaWAN port 17 | webport (int): Web server port 18 | apitoken (str): REST API authentication token 19 | freqband (str): Frequency band 20 | netid (int): Network ID 21 | duplicateperiod (int): Period (seconds) used to check for duplicate messages 22 | fcrelaxed (bool): Relaxed frame count mode 23 | otaastart (int): OTAA range start address 24 | otaaend (int): OTAA range end address 25 | macqueuing (bool): Queue downlink MAC commands 26 | macqueuelimit (int): Time (seconds) that MAC Commands can remain in the queue 27 | adrenable (bool): Adapative data rate enable 28 | adrmargin (int): SNR margin added to the calculation of adaptive data rate steps 29 | adrcycletime (int): Period (seconds) for ADR control cycle 30 | adrmessagetime (int): Minimum inter-ADR message time (seconds) 31 | """ 32 | 33 | TABLENAME = 'config' 34 | 35 | @classmethod 36 | @inlineCallbacks 37 | def loadFactoryDefaults(self): 38 | """Populate NetServer configuration defaults.""" 39 | 40 | # Server configuration 41 | c = Config() 42 | c.defaults() 43 | 44 | yield c.save() 45 | returnValue(c) 46 | 47 | def check(self): 48 | """Validate the system configuration object. 49 | 50 | Returns: 51 | valid (bool), message(dict): (True, empty) on success, 52 | (False, error message dict) otherwise. 53 | """ 54 | messages = {} 55 | 56 | if self.name == '': 57 | messages['listen'] = 'Invalid name' 58 | 59 | if self.listen != '' and not (validIPv4Address(self.listen) or 60 | validIPv6Address(self.listen)): 61 | messages['listen'] = 'Invalid IP address' 62 | 63 | if self.port < 1 or self.port > 65535: 64 | messages['port'] = 'Invalid server port' 65 | 66 | if self.webport < 1 or self.webport > 65535: 67 | messages['webport'] = 'Invalid web port' 68 | 69 | if self.webport < 1 or self.webport > 65535: 70 | messages['webport'] = 'Invalid web port' 71 | 72 | if self.freqband not in LoraBand.BANDS: 73 | messages['freqband'] = 'Invalid frequency band' 74 | 75 | if self.netid < 1 or self.netid > int('0xFFFFFF', 16): 76 | messages['netid'] = 'Invalid network ID' 77 | 78 | if self.duplicateperiod < 1 or self.duplicateperiod > 60: 79 | messages['netid'] = 'Invalid duplicate period' 80 | 81 | if self.otaastart < 1 or self.otaastart > int('0xFFFFFFFF', 16): 82 | messages['otaastart'] = 'Invalid OTAA start address' 83 | elif self.otaaend < 1 or self.otaaend > int('0xFFFFFFFF', 16) \ 84 | or self.otaaend <= self.otaastart: 85 | messages['otaastart'] = 'Invalid OTAA end address' 86 | 87 | if self.macqueuelimit < 60 or self.macqueuelimit > 86400: 88 | messages['macqueuelimit'] = 'Invalid MAC queueing limit' 89 | 90 | if self.adrmargin < 0.0: 91 | messages['adrmargin'] = 'Invalid ADR SNR margin' 92 | 93 | if self.adrcycletime < 60: 94 | messages['adrcycletime'] = 'Invalid ADR cycle time' 95 | 96 | if self.adrmessagetime < 1: 97 | messages['adrmessagetime'] = 'Invalid ADR message time' 98 | 99 | valid = not any(messages) 100 | return valid, messages 101 | 102 | def defaults(self): 103 | """Populate server configuration defaults 104 | 105 | """ 106 | self.name = 'floranet' 107 | self.listen = '0.0.0.0' 108 | self.port = 1700 109 | self.webport = 8000 110 | self.apitoken = 'IMxHj@wfNkym@*+V85Rs^G= server.config.otaastart and \ 77 | self.devaddr <= server.config.otaaend: 78 | messages['devaddr'] = "Device devaddr " \ 79 | "{} ".format(devaddrString(self.devaddr)) + \ 80 | "is within configured OTAA address range" 81 | 82 | valid = not any(messages) 83 | returnValue((valid, messages)) 84 | 85 | def isClassA(self): 86 | """Check if device is class A""" 87 | return self.devclass == 'a' 88 | 89 | def isClassB(self): 90 | """Check if device is class B""" 91 | return self.devclass == 'b' 92 | 93 | def isClassC(self): 94 | """Check if device is class C""" 95 | return self.devclass == 'c' 96 | 97 | def checkDevNonce(self, message): 98 | """Check the devnonce is not being repeated 99 | 100 | Args: 101 | message (JoinRequestMessage): Join request message 102 | 103 | Returns True if message is valid, otherwise False 104 | """ 105 | if self.devnonce is None: 106 | self.devnonce = [] 107 | 108 | # If the devnonce has been seen previously, return False 109 | if message.devnonce in self.devnonce: 110 | return False 111 | # If we have exceeded the history length, pop the oldest devnonce 112 | if len(self.devnonce) >= 20: 113 | self.devnonce.pop(0) 114 | self.devnonce.append(message.devnonce) 115 | return True 116 | 117 | def checkFrameCount(self, fcntup, maxfcntgap, relaxed): 118 | """Sync fcntup counter with received value 119 | 120 | The value received must have incremented compared to current 121 | counter value and must be less than the gap value specified 122 | by MAX_FCNT_GAP after considering rollovers. Otherwise, 123 | too many frames have been lost. 124 | 125 | Args: 126 | fcntup (int): Received fcntup value 127 | maxfcntgap (int): MAX_FCNT_GAP, band specific 128 | relaxed (bool): frame count relaxed flag 129 | 130 | Returns: 131 | True if fcntup is within the limit, otherwise False. 132 | 133 | """ 134 | # Relxed mode. If fcntup <=1 then set fcntdown to zero 135 | # and the device fcntup to match. 136 | if relaxed and fcntup <= 1: 137 | self.fcntdown = 0 138 | self.fcntup = fcntup 139 | self.fcnterror = False 140 | elif fcntup > (self.fcntup + maxfcntgap): 141 | self.fcnterror = True 142 | elif fcntup < self.fcntup and (65535 - self.fcntup + fcntup) > maxfcntgap: 143 | self.fcnterror = True 144 | else: 145 | self.fcntup = fcntup 146 | self.fcnterror = False 147 | 148 | return not self.fcnterror 149 | 150 | def updateSNR(self, lsnr): 151 | """Update Device SNR measures 152 | 153 | Updates the most recent received device SNR measure. 154 | We keep 11 samples for each device, and use the 155 | snr_pointer attribute to maintain the current sample 156 | index. 157 | 158 | Args: 159 | lsnr (float): Latest link SNR measure. 160 | 161 | """ 162 | # Check we have a SNR measure 163 | if lsnr is None: 164 | return 165 | 166 | # Check if this is the first SNR reading 167 | if self.snr is None: 168 | self.snr = [] 169 | 170 | # Update the current SNR reading 171 | if len(self.snr) == 11: 172 | self.snr.pop(0) 173 | self.snr.append(lsnr) 174 | 175 | # Update the average SNR, ensure we have at least 6 readings 176 | if len(self.snr) >= 6: 177 | self.snr_average = sum(self.snr[-6:])/6.0 178 | 179 | def getADRDatarate(self, band, margin): 180 | """Determine the optimal datarate that will achieve the 181 | objective margin in the given band. 182 | 183 | We assume each increase in datarate step (e.g. DR0 to DR1) requires 184 | an additional 3dB in SNR. 185 | 186 | Args: 187 | band (Band): Band in use 188 | margin (float): Target margin in dB 189 | 190 | Returns: 191 | Optimal datarate as a string on success, otherwise None. 192 | """ 193 | if not hasattr(self, 'snr_average') or self.snr_average is None: 194 | return None 195 | 196 | # Target thresholds that the average must exceed. Note range(0,4) 197 | # refers to the first four (upstream) indices of the band.datarate 198 | # list. These are DR0, DR1, DR2, DR3 199 | thresholds = [float(i) * 3.0 + margin for i in range(0,4)] 200 | 201 | # If we have an average SNR less than the lowest threshold, 202 | # return the lowest DR 203 | if self.snr_average < thresholds[0]: 204 | return band.datarate[0] 205 | 206 | # Find the index of lowest threshold that the SNR average just exceeds 207 | i = [n for n,v in enumerate(thresholds) if self.snr_average >= v][-1] 208 | return band.datarate[i] 209 | -------------------------------------------------------------------------------- /floranet/models/gateway.py: -------------------------------------------------------------------------------- 1 | from twistar.dbobject import DBObject 2 | 3 | from twisted.internet.defer import inlineCallbacks, returnValue 4 | 5 | from model import Model 6 | 7 | class Gateway(Model): 8 | """LoRa gateway model 9 | 10 | Attributes: 11 | host (str): IP address 12 | name (str): Gateway name 13 | eui (int): Gateway EUI 14 | enabled (bool): Enable flag 15 | power (int): Transmit power for downlink messages (dBm) 16 | port (int): UDP port to send PULL_RESP messages 17 | created (str): Timestamp when the gateway object is created 18 | updated (str): Timestamp when the gateway object is updated 19 | """ 20 | 21 | TABLENAME = 'gateways' 22 | 23 | def valid(self): 24 | """Validate a gateway object. 25 | 26 | Args: 27 | new (bool): New device flag 28 | 29 | Returns: 30 | valid (bool), message(dict): (True, {}) on success, 31 | (False, message dict) otherwise. 32 | """ 33 | messages = {} 34 | 35 | # Check power 36 | if not isinstance(self.power, int) or self.power < 0 or self.power > 30: 37 | messages['error'] = "Gateway power is not within the required range." 38 | 39 | valid = not any(messages) 40 | return((valid, messages)) 41 | -------------------------------------------------------------------------------- /floranet/models/model.py: -------------------------------------------------------------------------------- 1 | from twistar.dbobject import DBObject 2 | import datetime 3 | import pytz 4 | 5 | from twisted.internet.defer import inlineCallbacks, returnValue 6 | 7 | class Model(DBObject): 8 | """Model base class 9 | 10 | """ 11 | def beforeCreate(self): 12 | """Called before a new object is created. 13 | 14 | Returns: 15 | True on success. If False is returned, then the object is 16 | not saved in the database. 17 | """ 18 | self.created = datetime.datetime.now(tz=pytz.utc).isoformat() 19 | return True 20 | 21 | def beforeSave(self): 22 | """Called before an existing object is saved. 23 | 24 | This method is called after beforeCreate when an object is being 25 | created, and after beforeUpdate when an existing object 26 | (whose id is not None) is being saved. 27 | 28 | Returns: 29 | True on success. If False is returned, then the object is 30 | not saved in the database. 31 | """ 32 | self.updated = datetime.datetime.now(tz=pytz.utc).isoformat() 33 | return True 34 | 35 | def update(self, *args, **kwargs): 36 | """Updates the object with a variable list of attributes. 37 | 38 | """ 39 | if not kwargs: 40 | return 41 | 42 | self.beforeSave() 43 | 44 | def _doupdate(txn): 45 | return self._config.update(self.TABLENAME, kwargs, where=['id = ?', self.id], txn=txn) 46 | 47 | # We don't want to return the cursor - so add a blank callback returning this object 48 | for attr,v in kwargs.iteritems(): 49 | setattr(self, attr, v) 50 | return self._config.runInteraction(_doupdate).addCallback(lambda _: self) 51 | 52 | -------------------------------------------------------------------------------- /floranet/multitech/conduit/AU_global_conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "SX1301_conf": { 3 | "lorawan_public": true, 4 | "clksrc": 1, 5 | "clksrc_desc": "radio_1 provides clock to concentrator", 6 | "radio_0": { 7 | "enable": true, 8 | "type": "SX1257", 9 | "freq": 915500000, 10 | "rssi_offset": -166.0, 11 | "tx_enable": true 12 | }, 13 | "radio_1": { 14 | "enable": true, 15 | "type": "SX1257", 16 | "freq": 916300000, 17 | "rssi_offset": -166.0, 18 | "tx_enable": false 19 | }, 20 | "chan_multiSF_0": { 21 | "desc": "Lora MAC, 125kHz, all SF, 916.8 MHz", 22 | "enable": true, 23 | "radio": 0, 24 | "if": -300000 25 | }, 26 | "chan_multiSF_1": { 27 | "desc": "Lora MAC, 125kHz, all SF, 917.0 MHz", 28 | "enable": true, 29 | "radio": 0, 30 | "if": -100000 31 | }, 32 | "chan_multiSF_2": { 33 | "desc": "Lora MAC, 125kHz, all SF, 917.2 MHz", 34 | "enable": true, 35 | "radio": 0, 36 | "if": 100000 37 | }, 38 | "chan_multiSF_3": { 39 | "desc": "Lora MAC, 125kHz, all SF, 917.4 MHz", 40 | "enable": true, 41 | "radio": 0, 42 | "if": 300000 43 | }, 44 | "chan_multiSF_4": { 45 | "desc": "Lora MAC, 125kHz, all SF, 917.6 MHz", 46 | "enable": true, 47 | "radio": 1, 48 | "if": -300000 49 | }, 50 | "chan_multiSF_5": { 51 | "desc": "Lora MAC, 125kHz, all SF, 917.8 MHz", 52 | "enable": true, 53 | "radio": 1, 54 | "if": -100000 55 | }, 56 | "chan_multiSF_6": { 57 | "desc": "Lora MAC, 125kHz, all SF, 918.0 MHz", 58 | "enable": true, 59 | "radio": 1, 60 | "if": 100000 61 | }, 62 | "chan_multiSF_7": { 63 | "desc": "Lora MAC, 125kHz, all SF, 918.2 MHz", 64 | "enable": true, 65 | "radio": 1, 66 | "if": 300000 67 | }, 68 | "chan_Lora_std": { 69 | "desc": "Lora MAC, 500kHz, SF8, 917.5 MHz", 70 | "enable": true, 71 | "radio": 0, 72 | "if": 400000, 73 | "bandwidth": 500000, 74 | "spread_factor": 8 75 | }, 76 | "chan_FSK": { 77 | "desc": "FSK disabled", 78 | "enable": false 79 | }, 80 | "tx_lut_0": { 81 | "desc": "TX gain table, index 0", 82 | "pa_gain": 0, 83 | "mix_gain": 8, 84 | "rf_power": -6, 85 | "dig_gain": 0 86 | }, 87 | "tx_lut_1": { 88 | "desc": "TX gain table, index 1", 89 | "pa_gain": 0, 90 | "mix_gain": 10, 91 | "rf_power": -3, 92 | "dig_gain": 0 93 | }, 94 | "tx_lut_2": { 95 | "desc": "TX gain table, index 2", 96 | "pa_gain": 0, 97 | "mix_gain": 12, 98 | "rf_power": 0, 99 | "dig_gain": 0 100 | }, 101 | "tx_lut_3": { 102 | "desc": "TX gain table, index 3", 103 | "pa_gain": 1, 104 | "mix_gain": 8, 105 | "rf_power": 3, 106 | "dig_gain": 0 107 | }, 108 | "tx_lut_4": { 109 | "desc": "TX gain table, index 4", 110 | "pa_gain": 1, 111 | "mix_gain": 10, 112 | "rf_power": 6, 113 | "dig_gain": 0 114 | }, 115 | "tx_lut_5": { 116 | "desc": "TX gain table, index 5", 117 | "pa_gain": 1, 118 | "mix_gain": 12, 119 | "rf_power": 10, 120 | "dig_gain": 0 121 | }, 122 | "tx_lut_6": { 123 | "desc": "TX gain table, index 6", 124 | "pa_gain": 1, 125 | "mix_gain": 13, 126 | "rf_power": 11, 127 | "dig_gain": 0 128 | }, 129 | "tx_lut_7": { 130 | "desc": "TX gain table, index 7", 131 | "pa_gain": 2, 132 | "mix_gain": 9, 133 | "rf_power": 12, 134 | "dig_gain": 0 135 | }, 136 | "tx_lut_8": { 137 | "desc": "TX gain table, index 8", 138 | "pa_gain": 1, 139 | "mix_gain": 15, 140 | "rf_power": 13, 141 | "dig_gain": 0 142 | }, 143 | "tx_lut_9": { 144 | "desc": "TX gain table, index 9", 145 | "pa_gain": 2, 146 | "mix_gain": 10, 147 | "rf_power": 14, 148 | "dig_gain": 0 149 | }, 150 | "tx_lut_10": { 151 | "desc": "TX gain table, index 10", 152 | "pa_gain": 2, 153 | "mix_gain": 11, 154 | "rf_power": 16, 155 | "dig_gain": 0 156 | }, 157 | "tx_lut_11": { 158 | "desc": "TX gain table, index 11", 159 | "pa_gain": 3, 160 | "mix_gain": 9, 161 | "rf_power": 20, 162 | "dig_gain": 0 163 | }, 164 | "tx_lut_12": { 165 | "desc": "TX gain table, index 12", 166 | "pa_gain": 3, 167 | "mix_gain": 10, 168 | "rf_power": 23, 169 | "dig_gain": 0 170 | }, 171 | "tx_lut_13": { 172 | "desc": "TX gain table, index 13", 173 | "pa_gain": 3, 174 | "mix_gain": 11, 175 | "rf_power": 25, 176 | "dig_gain": 0 177 | }, 178 | "tx_lut_14": { 179 | "desc": "TX gain table, index 14", 180 | "pa_gain": 3, 181 | "mix_gain": 12, 182 | "rf_power": 26, 183 | "dig_gain": 0 184 | }, 185 | "tx_lut_15": { 186 | "desc": "TX gain table, index 15", 187 | "pa_gain": 3, 188 | "mix_gain": 14, 189 | "rf_power": 27, 190 | "dig_gain": 0 191 | } 192 | }, 193 | "gateway_conf": { 194 | "gateway_ID" : "00:08:00:4A:03:B0", 195 | "server_address": "192.168.1.100", 196 | "serv_port_up": 1700, 197 | "serv_port_down": 1700, 198 | "forward_crc_error": false, 199 | "servers": [ { 200 | "server_address": "192.168.1.100", 201 | "serv_port_up": 1700, 202 | "serv_port_down": 1700, 203 | "serv_enabled": true 204 | } ] 205 | } 206 | 207 | } -------------------------------------------------------------------------------- /floranet/multitech/conduit/US_global_conf.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "SX1301_conf": { 4 | "lorawan_public": true, 5 | "clksrc": 1, 6 | "clksrc_desc": "radio_1 provides clock to concentrator", 7 | "radio_0": { 8 | "enable": true, 9 | "type": "SX1257", 10 | "freq": 902600000, 11 | "rssi_offset": -166.0, 12 | "tx_enable": true 13 | }, 14 | "radio_1": { 15 | "enable": true, 16 | "type": "SX1257", 17 | "freq": 903400000, 18 | "rssi_offset": -166.0, 19 | "tx_enable": false 20 | }, 21 | "chan_multiSF_0": { 22 | "desc": "Lora MAC, 125kHz, all SF, 916.8 MHz", 23 | "enable": true, 24 | "radio": 0, 25 | "if": -300000 26 | }, 27 | "chan_multiSF_1": { 28 | "desc": "Lora MAC, 125kHz, all SF, 917.0 MHz", 29 | "enable": true, 30 | "radio": 0, 31 | "if": -100000 32 | }, 33 | "chan_multiSF_2": { 34 | "desc": "Lora MAC, 125kHz, all SF, 917.2 MHz", 35 | "enable": true, 36 | "radio": 0, 37 | "if": 100000 38 | }, 39 | "chan_multiSF_3": { 40 | "desc": "Lora MAC, 125kHz, all SF, 917.4 MHz", 41 | "enable": true, 42 | "radio": 0, 43 | "if": 300000 44 | }, 45 | "chan_multiSF_4": { 46 | "desc": "Lora MAC, 125kHz, all SF, 917.6 MHz", 47 | "enable": true, 48 | "radio": 1, 49 | "if": -300000 50 | }, 51 | "chan_multiSF_5": { 52 | "desc": "Lora MAC, 125kHz, all SF, 917.8 MHz", 53 | "enable": true, 54 | "radio": 1, 55 | "if": -100000 56 | }, 57 | "chan_multiSF_6": { 58 | "desc": "Lora MAC, 125kHz, all SF, 918.0 MHz", 59 | "enable": true, 60 | "radio": 1, 61 | "if": 100000 62 | }, 63 | "chan_multiSF_7": { 64 | "desc": "Lora MAC, 125kHz, all SF, 918.2 MHz", 65 | "enable": true, 66 | "radio": 1, 67 | "if": 300000 68 | }, 69 | "chan_Lora_std": { 70 | "desc": "Lora MAC, 500kHz, SF8, 917.5 MHz", 71 | "enable": true, 72 | "radio": 0, 73 | "if": 400000, 74 | "bandwidth": 500000, 75 | "spread_factor": 8 76 | }, 77 | "chan_FSK": { 78 | "desc": "FSK disabled", 79 | "enable": false 80 | }, 81 | "tx_lut_0": { 82 | "desc": "TX gain table, index 0", 83 | "pa_gain": 0, 84 | "mix_gain": 8, 85 | "rf_power": -6, 86 | "dig_gain": 0 87 | }, 88 | "tx_lut_1": { 89 | "desc": "TX gain table, index 1", 90 | "pa_gain": 0, 91 | "mix_gain": 10, 92 | "rf_power": -3, 93 | "dig_gain": 0 94 | }, 95 | "tx_lut_2": { 96 | "desc": "TX gain table, index 2", 97 | "pa_gain": 0, 98 | "mix_gain": 12, 99 | "rf_power": 0, 100 | "dig_gain": 0 101 | }, 102 | "tx_lut_3": { 103 | "desc": "TX gain table, index 3", 104 | "pa_gain": 1, 105 | "mix_gain": 8, 106 | "rf_power": 3, 107 | "dig_gain": 0 108 | }, 109 | "tx_lut_4": { 110 | "desc": "TX gain table, index 4", 111 | "pa_gain": 1, 112 | "mix_gain": 10, 113 | "rf_power": 6, 114 | "dig_gain": 0 115 | }, 116 | "tx_lut_5": { 117 | "desc": "TX gain table, index 5", 118 | "pa_gain": 1, 119 | "mix_gain": 12, 120 | "rf_power": 10, 121 | "dig_gain": 0 122 | }, 123 | "tx_lut_6": { 124 | "desc": "TX gain table, index 6", 125 | "pa_gain": 1, 126 | "mix_gain": 13, 127 | "rf_power": 11, 128 | "dig_gain": 0 129 | }, 130 | "tx_lut_7": { 131 | "desc": "TX gain table, index 7", 132 | "pa_gain": 2, 133 | "mix_gain": 9, 134 | "rf_power": 12, 135 | "dig_gain": 0 136 | }, 137 | "tx_lut_8": { 138 | "desc": "TX gain table, index 8", 139 | "pa_gain": 1, 140 | "mix_gain": 15, 141 | "rf_power": 13, 142 | "dig_gain": 0 143 | }, 144 | "tx_lut_9": { 145 | "desc": "TX gain table, index 9", 146 | "pa_gain": 2, 147 | "mix_gain": 10, 148 | "rf_power": 14, 149 | "dig_gain": 0 150 | }, 151 | "tx_lut_10": { 152 | "desc": "TX gain table, index 10", 153 | "pa_gain": 2, 154 | "mix_gain": 11, 155 | "rf_power": 16, 156 | "dig_gain": 0 157 | }, 158 | "tx_lut_11": { 159 | "desc": "TX gain table, index 11", 160 | "pa_gain": 3, 161 | "mix_gain": 9, 162 | "rf_power": 20, 163 | "dig_gain": 0 164 | }, 165 | "tx_lut_12": { 166 | "desc": "TX gain table, index 12", 167 | "pa_gain": 3, 168 | "mix_gain": 10, 169 | "rf_power": 23, 170 | "dig_gain": 0 171 | }, 172 | "tx_lut_13": { 173 | "desc": "TX gain table, index 13", 174 | "pa_gain": 3, 175 | "mix_gain": 11, 176 | "rf_power": 25, 177 | "dig_gain": 0 178 | }, 179 | "tx_lut_14": { 180 | "desc": "TX gain table, index 14", 181 | "pa_gain": 3, 182 | "mix_gain": 12, 183 | "rf_power": 26, 184 | "dig_gain": 0 185 | }, 186 | "tx_lut_15": { 187 | "desc": "TX gain table, index 15", 188 | "pa_gain": 3, 189 | "mix_gain": 14, 190 | "rf_power": 27, 191 | "dig_gain": 0 192 | } 193 | }, 194 | "gateway_conf": { 195 | "server_address": "192.168.1.100", 196 | "serv_port_up": 1700, 197 | "serv_port_down": 1700, 198 | "servers": [ { 199 | "server_address": "192.168.1.100", 200 | "serv_port_up": 1700, 201 | "serv_port_down": 1700, 202 | "serv_enabled": true 203 | } ] 204 | } 205 | 206 | } -------------------------------------------------------------------------------- /floranet/multitech/mdot/mDot-demo-abp-au915.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fluent-networks/floranet/f025825fa234194646d9831bd52fa3a3a55c0666/floranet/multitech/mdot/mDot-demo-abp-au915.bin -------------------------------------------------------------------------------- /floranet/multitech/mdot/mDot-demo-abp-us915.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fluent-networks/floranet/f025825fa234194646d9831bd52fa3a3a55c0666/floranet/multitech/mdot/mDot-demo-abp-us915.bin -------------------------------------------------------------------------------- /floranet/multitech/mdot/mDot-demo-otaa-au915.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fluent-networks/floranet/f025825fa234194646d9831bd52fa3a3a55c0666/floranet/multitech/mdot/mDot-demo-otaa-au915.bin -------------------------------------------------------------------------------- /floranet/multitech/mdot/mDot-demo-otaa-us915.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fluent-networks/floranet/f025825fa234194646d9831bd52fa3a3a55c0666/floranet/multitech/mdot/mDot-demo-otaa-us915.bin -------------------------------------------------------------------------------- /floranet/test/__init__.py: -------------------------------------------------------------------------------- 1 | #TODO: add some useful description 2 | """Placeholder 3 | """ 4 | pass 5 | 6 | 7 | -------------------------------------------------------------------------------- /floranet/test/integration/__init__.py: -------------------------------------------------------------------------------- 1 | #TODO: add some useful description 2 | """Placeholder 3 | """ 4 | pass 5 | 6 | 7 | -------------------------------------------------------------------------------- /floranet/test/integration/database.cfg: -------------------------------------------------------------------------------- 1 | # Database connection parameters 2 | [database] 3 | host = 127.0.0.1 4 | user = postgres 5 | password = postgres 6 | database = floranet 7 | 8 | -------------------------------------------------------------------------------- /floranet/test/integration/test_azure_https.py: -------------------------------------------------------------------------------- 1 | import os 2 | import struct 3 | 4 | from twisted.trial import unittest 5 | 6 | from twisted.internet.defer import inlineCallbacks 7 | from twistar.registry import Registry 8 | 9 | from floranet.models.device import Device 10 | from floranet.models.application import Application 11 | from floranet.appserver.azure_iot_https import AzureIotHttps 12 | from floranet.database import Database 13 | 14 | """ 15 | Azure IoT HTTP test application interface to use. Configure 16 | this interface with the IoT Hub hostname, key name and 17 | key value: 18 | floranet> interface add azure protocol=https name=AzureTest 19 | iothost=test-floranet.azure-devices.net keyname=iothubowner 20 | keyvalue=CgqCQ1nMMk3TYDU6vYx2wgipQfX0Av7STc8 pollinterval=25 21 | """ 22 | AzureIoTHubName = 'AzureTest' 23 | 24 | """ 25 | Azure IoT Hub Device Explorer should be used to verify outbound 26 | (Device to Cloud) messages are received, and to send inbound 27 | (Cloud to Device) test messages. 28 | """ 29 | 30 | class AzureIotHTTPSTest(unittest.TestCase): 31 | """Test send and receive messages to Azure IoT Hub 32 | 33 | """ 34 | 35 | @inlineCallbacks 36 | def setUp(self): 37 | 38 | # Bootstrap the database 39 | fpath = os.path.realpath(__file__) 40 | config = os.path.dirname(fpath) + '/database.cfg' 41 | 42 | db = Database() 43 | db.parseConfig(config) 44 | db.start() 45 | db.register() 46 | 47 | self.device = yield Device.find(where=['appname = ?', 48 | 'azuredevice02'], limit=1) 49 | self.app = yield Application.find(where=['appeui = ?', 50 | self.device.appeui], limit=1) 51 | 52 | @inlineCallbacks 53 | def test_AzureIotHttps_outbound(self): 54 | """Test send of sample data to an Azure IoT Hub instance""" 55 | 56 | interface = yield AzureIotHttps.find(where=['name = ?', 57 | AzureIoTHubName], limit=1) 58 | 59 | port = 11 60 | appdata = struct.pack(' interface add azure protocol=mqtt name=AzureTest 19 | iothost=test-floranet.azure-devices.net keyname=iothubowner 20 | keyvalue=CgqCQ1nMMk3TYDU6vYx2wgipQfX0Av7STc8 21 | """ 22 | AzureIoTHubName = 'AzureMqttTest' 23 | 24 | """ 25 | Azure IoT Hub Device Explorer should be used to verify outbound 26 | (Device to Cloud) messages are received, and to send inbound 27 | (Cloud to Device) test messages. 28 | """ 29 | 30 | class AzureIotMQTTTest(unittest.TestCase): 31 | """Test send and receive messages to Azure IoT Hub 32 | 33 | """ 34 | 35 | @inlineCallbacks 36 | def setUp(self): 37 | 38 | # Bootstrap the database 39 | fpath = os.path.realpath(__file__) 40 | config = os.path.dirname(fpath) + '/database.cfg' 41 | log.start(True, '', True) 42 | 43 | db = Database() 44 | db.parseConfig(config) 45 | db.start() 46 | db.register() 47 | 48 | self.device = yield Device.find(where=['appname = ?', 49 | 'azuredevice02'], limit=1) 50 | self.app = yield Application.find(where=['appeui = ?', 51 | self.device.appeui], limit=1) 52 | 53 | @inlineCallbacks 54 | def test_AzureIotMqtt(self): 55 | """Test sending & receiving sample data to/from an 56 | Azure IoT Hub instance""" 57 | 58 | interface = yield AzureIotMqtt.find(where=['name = ?', 59 | AzureIoTHubName], limit=1) 60 | 61 | port = 11 62 | appdata = "{ Temperature: 42.3456 }" 63 | 64 | yield interface.start(None) 65 | yield interface.netServerReceived(self.device, self.app, port, appdata) 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /floranet/test/integration/test_file_text_store.py: -------------------------------------------------------------------------------- 1 | import os 2 | import struct 3 | 4 | from twisted.trial import unittest 5 | 6 | from twisted.internet.defer import inlineCallbacks 7 | from twistar.registry import Registry 8 | 9 | from floranet.models.device import Device 10 | from floranet.models.application import Application 11 | from floranet.appserver.file_text_store import FileTextStore 12 | from floranet.database import Database 13 | from floranet.log import log 14 | """ 15 | Text file store application interface to use. Configure 16 | this interface with the name and file 17 | floranet> interface add filetext name=Testfile file=/tmp/test.txt 18 | """ 19 | class FileTextStoreTest(unittest.TestCase): 20 | """Test sending message to a text file 21 | 22 | """ 23 | 24 | @inlineCallbacks 25 | def setUp(self): 26 | 27 | # Bootstrap the database 28 | fpath = os.path.realpath(__file__) 29 | config = os.path.dirname(fpath) + '/database.cfg' 30 | log.start(True, '', True) 31 | 32 | db = Database() 33 | db.parseConfig(config) 34 | db.start() 35 | db.register() 36 | 37 | self.device = yield Device.find(where=['name = ?', 38 | 'abp_device'], limit=1) 39 | self.app = yield Application.find(where=['appeui = ?', 40 | self.device.appeui], limit=1) 41 | 42 | @inlineCallbacks 43 | def test_FileTextStore(self): 44 | """Test sending data to a text file.""" 45 | 46 | interface = yield FileTextStore.find(where=['name = ?', 47 | 'Test'], limit=1) 48 | 49 | port = 15 50 | appdata = "{ Temperature: 42.3456 }" 51 | 52 | yield interface.start(None) 53 | yield interface.netServerReceived(self.device, self.app, port, appdata) 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /floranet/test/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Get script path 3 | SCRIPTPATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | # Derive the project path 5 | PROJECTPATH="${SCRIPTPATH}/../.." 6 | 7 | # Add the project path to PYTHONPATH 8 | export PYTHONPATH="${PROJECTPATH}:${PYTHONPATH}" 9 | 10 | # Run the unit tests. Web tests run in a separate reactor. 11 | unittests=(floranet web) 12 | for u in "${unittests[@]}" 13 | do 14 | (cd /tmp; trial -x floranet.test.unit.${u}) 15 | done 16 | 17 | # Run the integration tests 18 | # (cd /tmp; trial -x floranet.test.integration) 19 | -------------------------------------------------------------------------------- /floranet/test/unit/__init__.py: -------------------------------------------------------------------------------- 1 | #TODO: add some useful description 2 | """Placeholder 3 | """ 4 | pass 5 | 6 | 7 | -------------------------------------------------------------------------------- /floranet/test/unit/default.cfg: -------------------------------------------------------------------------------- 1 | [server] 2 | # Interface to listen for incoming LoRaWAN packets. 3 | # Leave blank to listen on all interfaces. 4 | listen = 5 | 6 | # The UDP port the server will listen on. 7 | port = 1700 8 | 9 | # The TCP port the web server will listen on. 10 | webport = 8000 11 | 12 | # The Rest API Token 13 | apitoken = 'IMxHj@wfNkym@*+V85Rs^G= 1.2) 20 | 21 | def test_euiString(self): 22 | """Test euiString""" 23 | expected = "0f0e.0e0d.0001.0203" 24 | 25 | eui = int('0x0f0e0e0d00010203', 16) 26 | result = util.euiString(eui) 27 | 28 | self.assertEqual(expected, result) 29 | 30 | def test_devaddrString(self): 31 | """Test devaddrString""" 32 | expected = "0610.0000" 33 | 34 | eui = int('0x06100000', 16) 35 | result = util.devaddrString(eui) 36 | 37 | self.assertEqual(expected, result) 38 | 39 | def test_intPackBytes(self, ): 40 | """Test intPackBytes""" 41 | expected = '\x01~\x15\x168\xae\xc2\xa6\xab\xf7%\x88\t\xcfO<' 42 | 43 | k = int('0x017E151638AEC2A6ABF7258809CF4F3C', 16) 44 | length = 16 45 | result = util.intPackBytes(k, length) 46 | 47 | self.assertEqual(expected, result) 48 | 49 | def test_intUnpackBytes(self, ): 50 | """Test intUnpackBytes""" 51 | expected = int('0x017E151638AEC2A6ABF7258809CF4F3C', 16) 52 | 53 | data = '\x01~\x15\x168\xae\xc2\xa6\xab\xf7%\x88\t\xcfO<' 54 | result = util.intUnpackBytes(data) 55 | 56 | self.assertEqual(expected, result) 57 | 58 | def test_bytesInt128(self, ): 59 | """Test bytesInt128""" 60 | expected = 5634002656530987591323243570L 61 | 62 | data = 'xV4\x12\x00\x00\x00\x002Tv\x98\x00\x00\x00\x00' 63 | result = util.bytesInt128(data) 64 | 65 | self.assertEqual(expected, result) 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /floranet/test/unit/floranet/test_wan.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from twisted.trial import unittest 5 | from twisted.internet.defer import inlineCallbacks 6 | from mock import patch, MagicMock 7 | 8 | from twistar.registry import Registry 9 | from floranet.models.model import Model 10 | from floranet.models.config import Config 11 | from floranet.models.gateway import Gateway 12 | from floranet.netserver import NetServer 13 | import floranet.lora.wan as lora_wan 14 | 15 | class RxpkTest(unittest.TestCase): 16 | """Test Rxpk class""" 17 | 18 | def setUp(self): 19 | """Test setup""" 20 | self.rxpk = '{"rxpk":[{"tmst":4220249403,' \ 21 | '"time":"2016-07-24T00:55:08.864752Z",' \ 22 | '"chan":3,"rfch":0,"freq":915.800000,"stat":-1,' \ 23 | '"modu":"LORA","datr":"SF7BW125","codr":"4/7",' \ 24 | '"lsnr":-11.0,"rssi":-109,"size":54,' \ 25 | '"data":"n/uSwM0LIED8X6QV0mJMjC6oc2HOWFpCfmTry' \ 26 | '1MdUjphiuUua2mBkwYrdD2Dc9id2/zq7Soe"}]}' 27 | self.rxpkm = '{"rxpk":[{"tmst":4220249403,' \ 28 | '"time":"2016-07-24T00:55:08.864752Z",' \ 29 | '"chan":3,"rfch":0,"freq":915.800000,"stat":-1,' \ 30 | '"modu":"LORA","datr":"SF7BW125","codr":"4/7",' \ 31 | '"lsnr":-11.0,"rssi":-109,"size":54,' \ 32 | '"data":"n/uSwM0LIED8X6QV0mJMjC6oc2HOWFpCfmTry' \ 33 | '1MdUjphiuUua2mBkwYrdD2Dc9id2/zq7Soe"},' \ 34 | '{"tmst":3176756475,' \ 35 | '"time":"2016-07-24T01:49:20.342331Z",' \ 36 | '"chan":1,"rfch":0,"freq":915.400000,"stat":-1,' \ 37 | '"modu":"LORA","datr":"SF7BW125","codr":"4/7",' \ 38 | '"lsnr":-11.5,"rssi":-108,"size":199,' \ 39 | '"data":"T9lh8PZb4qa/YqXa4XDBZAtUnHLpnHg9hdUo1g' \ 40 | 'Y7fcd0yvwPBs+MoRBvl/JdPY/1v1ZJbgh=="}]}' 41 | 42 | def test_decode(self): 43 | """Test decode method""" 44 | # Single rxpk decode 45 | d = json.loads(self.rxpk) 46 | expected = int(d['rxpk'][0]['tmst']) 47 | 48 | rxpk = lora_wan.Rxpk.decode(d['rxpk'][0]) 49 | result = rxpk.tmst 50 | 51 | self.assertEqual(expected, result) 52 | 53 | # Test multiple rxpk decode 54 | d = json.loads(self.rxpkm) 55 | expected = [int(d['rxpk'][0]['tmst']), 56 | int(d['rxpk'][1]['tmst'])] 57 | 58 | result = [] 59 | for r in d['rxpk']: 60 | rxpk = lora_wan.Rxpk.decode(r) 61 | result.append(rxpk.tmst) 62 | 63 | self.assertEqual(expected, result) 64 | 65 | class TxpkTest(unittest.TestCase): 66 | """Test Txpk class""" 67 | 68 | def test_encode(self): 69 | """Test encode method""" 70 | expected = '{"txpk":{"tmst":44995444,"freq":927.5,"rfch":0,' \ 71 | '"powe":26,"modu":"LORA","datr":"SF10BW500",' \ 72 | '"codr":"4/5","ipol":true,"size":11,' \ 73 | '"data":"aGVsbG93b3JsZCE","ncrc":false}}' 74 | 75 | txpk = lora_wan.Txpk(tmst=44995444, freq=927.5, rfch=0, powe=26, 76 | modu='LORA', datr='SF10BW500', codr='4/5', 77 | ipol=True, size=11, data='helloworld!', ncrc=False) 78 | result = txpk.encode() 79 | 80 | self.assertEqual(expected, result) 81 | 82 | class GatewayMessageTest(unittest.TestCase): 83 | """Test GatewayMessage class""" 84 | 85 | def test_decode(self): 86 | """Test decode method""" 87 | # Test PULLDATA decode 88 | # (version, token, id, gatewayEUI, ptype) 89 | expected = (1, 36687, lora_wan.PULL_DATA, 17988221336647925760L, None) 90 | 91 | data = '\x01O\x8f\x02\x00\x80\x00\x00\x00\x00\xa3\xf9' 92 | (host, port) = ('192.168.1.125', 55369) 93 | m = lora_wan.GatewayMessage.decode(data, (host,port)) 94 | result = (m.version, m.token, m.id, m.gatewayEUI, m.ptype) 95 | 96 | self.assertEqual(expected, result) 97 | 98 | # Test PULLACK decode 99 | expected = '\x01O\x8f\x04\x00\x80\x00\x00\x00\x00\xa3\xf9' 100 | 101 | m = lora_wan.GatewayMessage(version=1, token=36687, 102 | identifier=lora_wan.PULL_ACK, 103 | gatewayEUI=17988221336647925760L, 104 | remote=('192.168.1.125', 55369)) 105 | result = m.encode() 106 | 107 | self.assertEqual(expected, result) 108 | 109 | # Test PUSHDATA decode 110 | # (version, token, id, gatewayEUI, ptype) 111 | expected = (1, 50354, lora_wan.PUSH_DATA, 112 | 17988221336647925760L, 'rxpk') 113 | 114 | data = '\x01\xb2\xc4\x00\x00\x80\x00\x00\x00\x00\xa3\xf9' \ 115 | '{"rxpk":[{"tmst":2072854188,' \ 116 | '"time":"2016-09-06T21:02:05.128290Z",' \ 117 | '"chan":0,"rfch":0,"freq":915.200000,"stat":1,"modu":"LORA",' \ 118 | '"datr":"SF10BW125","codr":"4/5","lsnr":8.5,"rssi":-24,' \ 119 | '"size":14,"data":"QAAAEAaA5RUPNvKkWdA="}]}' 120 | (host, port) = ('192.168.1.125', 56035) 121 | m = lora_wan.GatewayMessage.decode(data, (host,port)) 122 | result = (m.version, m.token, m.id, m.gatewayEUI, m.ptype) 123 | 124 | self.assertEqual(expected, result) 125 | 126 | def test_encode(self): 127 | """Test encode method""" 128 | # Test PUSHACK 129 | expected = '\x01\xb2\xc4\x01' 130 | 131 | m = lora_wan.GatewayMessage(version=1, token=50354, 132 | identifier=lora_wan.PUSH_ACK, 133 | remote=('192.168.1.125', 55369)) 134 | result = m.encode() 135 | 136 | self.assertEqual(expected, result) 137 | 138 | class LoraWANTest(unittest.TestCase): 139 | """Test LoraWAN class""" 140 | 141 | @inlineCallbacks 142 | def setUp(self): 143 | """Test setup. Creates a new NetServer 144 | """ 145 | Registry.getConfig = MagicMock(return_value=None) 146 | 147 | ## Get factory default configuration 148 | with patch.object(Model, 'save', MagicMock()): 149 | config = yield Config.loadFactoryDefaults() 150 | server = NetServer(config) 151 | 152 | self.lora = lora_wan.LoraWAN(server) 153 | g = Gateway(host='192.168.1.125', name='Test', enabled=True, power=26) 154 | self.lora.gateways.append(g) 155 | 156 | def test_addGateway(self): 157 | """ Test addGateway method""" 158 | address = '192.168.1.199' 159 | expected = address 160 | 161 | g = Gateway(host=address, name='Test Add', enabled=True, power=26) 162 | self.lora.addGateway(g) 163 | result = self.lora.gateways[1].host 164 | 165 | self.assertEqual(expected, result) 166 | 167 | def test_updateGateway(self): 168 | """ Test updateGateway method""" 169 | address = '192.168.1.199' 170 | expected = address 171 | 172 | host = self.lora.gateways[0].host 173 | gateway = Gateway(host=address, name='Test Update', enabled=True, power=26) 174 | self.lora.updateGateway(host, gateway) 175 | result = self.lora.gateways[0].host 176 | 177 | self.assertEqual(expected, result) 178 | 179 | def test_deleteGateway(self): 180 | """Test updateGateway method""" 181 | expected = 0 182 | 183 | gateway = self.lora.gateways[0] 184 | self.lora.deleteGateway(gateway) 185 | result = len(self.lora.gateways) 186 | 187 | self.assertEqual(expected, result) 188 | 189 | def test_gateway(self): 190 | """Test gateway method""" 191 | address = '192.168.1.125' 192 | expected = address 193 | 194 | gateway = self.lora.gateway(address) 195 | result = gateway.host 196 | 197 | self.assertEqual(expected, result) 198 | -------------------------------------------------------------------------------- /floranet/test/unit/mock_dbobject.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.defer import inlineCallbacks, returnValue 2 | from twisted.internet import reactor 3 | 4 | from mock_reactor import reactorCall 5 | 6 | return_value = None 7 | 8 | @inlineCallbacks 9 | def all(*args, **kwargs): 10 | """DBObject.all() mock. 11 | 12 | Returns: 13 | return_value. 14 | """ 15 | yield reactorCall(args) 16 | returnValue(return_value) 17 | 18 | @inlineCallbacks 19 | def findSuccess(*args, **kwargs): 20 | """DBObject.find(limit=1) mock. Mocks successful find. 21 | 22 | Returns: 23 | return_value. 24 | """ 25 | yield reactorCall(args) 26 | returnValue(return_value) 27 | 28 | @inlineCallbacks 29 | def findFail(*args, **kwargs): 30 | """ DBObject.find(limit=1) mock. Mocks unsuccessful find. 31 | 32 | Returns: 33 | None. 34 | """ 35 | yield reactorCall(args) 36 | returnValue(None) 37 | 38 | @inlineCallbacks 39 | def findOne(*args, **kwargs): 40 | """DBObject.find(limit=1) mock. Mocks a multiple query 41 | where one object is found. 42 | 43 | Returns: 44 | List containing one return_value. 45 | """ 46 | yield reactorCall(args) 47 | returnValue([return_value]) 48 | -------------------------------------------------------------------------------- /floranet/test/unit/mock_model.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.defer import inlineCallbacks, returnValue 2 | 3 | from mock_reactor import reactorCall 4 | 5 | mock_object = None 6 | return_value = None 7 | 8 | @inlineCallbacks 9 | def update(*args, **kwargs): 10 | """Model update() mock. 11 | 12 | Mock the model update for the given object. 13 | 14 | """ 15 | for attr,v in kwargs.iteritems(): 16 | setattr(mock_object, attr, v) 17 | yield reactorCall(args) 18 | returnValue(return_value) 19 | -------------------------------------------------------------------------------- /floranet/test/unit/mock_reactor.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.defer import inlineCallbacks, Deferred 2 | from twisted.internet import reactor 3 | 4 | @inlineCallbacks 5 | def reactorCall(*args, **kwargs): 6 | d = Deferred() 7 | reactor.callLater(0, d.callback, args) 8 | yield d 9 | -------------------------------------------------------------------------------- /floranet/test/unit/mock_relationship.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.defer import inlineCallbacks, returnValue 2 | from twisted.internet import reactor 3 | 4 | from mock_reactor import reactorCall 5 | 6 | return_value = None 7 | 8 | @inlineCallbacks 9 | def find(*args, **kwargs): 10 | """Mock relationship find. 11 | 12 | Returns: 13 | return_value. 14 | """ 15 | yield reactorCall(args) 16 | returnValue(return_value) 17 | -------------------------------------------------------------------------------- /floranet/test/unit/web/__init__.py: -------------------------------------------------------------------------------- 1 | #TODO: add some useful description 2 | """Placeholder 3 | """ 4 | pass 5 | 6 | 7 | -------------------------------------------------------------------------------- /floranet/test/unit/web/test_webserver.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from twisted.trial import unittest 5 | from mock import patch, MagicMock 6 | 7 | from twisted.internet.defer import inlineCallbacks 8 | 9 | from flask import Flask, Request 10 | from flask_restful import Api, Resource 11 | from werkzeug.test import EnvironBuilder 12 | from twistar.registry import Registry 13 | 14 | from floranet.models.config import Config 15 | from floranet.models.model import Model 16 | from floranet.netserver import NetServer 17 | from floranet.web.webserver import WebServer 18 | 19 | class WebServerTest(unittest.TestCase): 20 | 21 | @inlineCallbacks 22 | def setUp(self): 23 | """Test setup. 24 | """ 25 | Registry.getConfig = MagicMock(return_value=None) 26 | 27 | # Get factory default configuration 28 | with patch.object(Model, 'save', MagicMock()): 29 | config = yield Config.loadFactoryDefaults() 30 | 31 | self.server = NetServer(config) 32 | self.webserver = WebServer(self.server) 33 | 34 | def test_load_user(self): 35 | """Test load_user method""" 36 | 37 | # Since request objects are immutable, we create 38 | # a request object with mutable values 39 | class TestRequest(object): 40 | 41 | def __init__(self, method, path, headers, data): 42 | self.method = method 43 | self.path = path 44 | self.headers = headers 45 | self.data = data 46 | 47 | def get_json(self): 48 | return self.data 49 | 50 | # Test JSON authorization 51 | request = TestRequest('GET', '', 52 | {'content-type': 'application/json'}, 53 | { 'token': self.server.config.apitoken }) 54 | result = self.webserver.load_user(request) 55 | 56 | self.assertTrue(result is not None) 57 | 58 | # Test JSON authorization failure 59 | request.data['token'] = '' 60 | 61 | result = self.webserver.load_user(request) 62 | 63 | self.assertTrue(result is None) 64 | 65 | # Test header authorization 66 | request.headers['Authorization'] = self.server.config.apitoken 67 | 68 | result = self.webserver.load_user(request) 69 | 70 | self.assertTrue(result is not None) 71 | 72 | # Test header authorization failure 73 | request.headers['Authorization'] = '' 74 | 75 | result = self.webserver.load_user(request) 76 | 77 | self.assertTrue(result is None) 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /floranet/util.py: -------------------------------------------------------------------------------- 1 | 2 | import struct 3 | import socket 4 | from twisted.internet import reactor 5 | from twisted.internet.defer import Deferred 6 | 7 | def txsleep(secs): 8 | """Simulate a reactor sleep 9 | 10 | Args: 11 | secs (float): time to sleep 12 | """ 13 | d = Deferred() 14 | reactor.callLater(secs, d.callback, None) 15 | return d 16 | 17 | def intHexString(n, length, sep=4): 18 | """Convert an integer to a dotted hex representation. 19 | 20 | Args: 21 | n (int): integer to convert 22 | length (int): number of hex bytes 23 | sep (int): dot seperator length 24 | 25 | Returns: 26 | The hex string representation. 27 | """ 28 | hstr = '' 29 | hlen = length * 2 30 | hexs = format(int(n), '0' + str(hlen) + 'x') 31 | for i in range(0, hlen, sep): 32 | hstr += hexs[i:i+sep] + '.' if i < (hlen-sep) else hexs[i:i+sep] 33 | return hstr 34 | 35 | def hexStringInt(h): 36 | """Convert a hex string representation to int. 37 | 38 | Args: 39 | h (str): hex string to convert 40 | """ 41 | istr = '0x' + h.translate(None, '.') 42 | return int(istr, 16) 43 | 44 | def euiString(eui): 45 | """Convert a Lora EUI to string hex representation. 46 | 47 | Args: 48 | eui (int): an 8 byte Lora EUI. 49 | 50 | Returns: 51 | The hex string representation. 52 | """ 53 | return intHexString(eui, 8) 54 | 55 | 56 | def devaddrString(devaddr): 57 | """Convert a 32 bit Lora DevAddr to string hex representation. 58 | 59 | Args: 60 | devaddr (int): a 6 byte Lora DevAddr. 61 | 62 | Returns: 63 | The hex string representation. 64 | """ 65 | return intHexString(devaddr, 4) 66 | 67 | def intPackBytes(n, length, endian='big'): 68 | """Convert an integer to a packed binary string representation. 69 | 70 | Args: 71 | n (int): Integer to convert 72 | length (int): converted string length 73 | endian (str): endian type: 'big' or 'little' 74 | 75 | Returns: 76 | A packed binary string. 77 | """ 78 | if length == 0: 79 | return '' 80 | h = '%x' % n 81 | s = ('0'*(len(h) % 2) + h).zfill(length*2).decode('hex') 82 | if endian == 'big': 83 | return s 84 | else: 85 | return s[::-1] 86 | 87 | def intUnpackBytes(data, endian='big'): 88 | """Convert an packed binary string representation to an integer. 89 | 90 | Args: 91 | data (str): packed binary data 92 | endian (str): endian type: 'big' or 'little' 93 | 94 | Returns: 95 | An integer. 96 | """ 97 | if isinstance(data, str): 98 | data = bytearray(data) 99 | if endian == 'big': 100 | data = reversed(data) 101 | num = 0 102 | for offset, byte in enumerate(data): 103 | num += byte << (offset * 8) 104 | return num 105 | 106 | def bytesInt128(data): 107 | """Convert a 128 bit packed binary string to an integer. 108 | 109 | Args: 110 | data (str): 128 bit packed binary data 111 | 112 | Returns: 113 | An integer. 114 | """ 115 | (a, b) = struct.unpack('': RestDevice, 35 | '/devices': RestDevices, 36 | # Application endpoints 37 | '/app/': RestApplication, 38 | '/apps': RestApplications, 39 | # Gateway endpoints 40 | '/gateway/': RestGateway, 41 | '/gateways': RestGateways, 42 | # Application interface endpoints 43 | '/interface/': RestAppInterface, 44 | '/interfaces': RestAppInterfaces, 45 | # Application property endpoints 46 | '/property/': RestAppProperty, 47 | '/propertys': RestAppPropertys 48 | } 49 | 50 | kwargs = {'restapi': self, 'server': self.server} 51 | for path,klass in self.resources.iteritems(): 52 | self.api.add_resource(klass, path, resource_class_kwargs=kwargs) 53 | 54 | -------------------------------------------------------------------------------- /floranet/web/rest/system.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | 3 | from flask_restful import Resource, reqparse, abort, inputs, fields, marshal 4 | from flask_login import login_required 5 | from twisted.internet.defer import inlineCallbacks, returnValue 6 | from crochet import wait_for, TimeoutError 7 | 8 | from floranet.models.config import Config 9 | from floranet.log import log 10 | 11 | # Crochet timeout. If the code block does not complete within this time, 12 | # a TimeoutError exception is raised. 13 | from __init__ import TIMEOUT 14 | 15 | class RestSystem(Resource): 16 | """System configuration resource base class. 17 | 18 | Attributes: 19 | restapi (RestApi): Flask Restful API object 20 | server (NetServer): FloraNet network server object 21 | fields (dict): Dictionary of attributes to be returned to a REST request 22 | parser (RequestParser): Flask RESTful request parser 23 | args (dict): Parsed request argument 24 | """ 25 | 26 | def __init__(self, **kwargs): 27 | self.restapi = kwargs['restapi'] 28 | self.server = kwargs['server'] 29 | self.fields = { 30 | 'name': fields.String, 31 | 'listen': fields.String, 32 | 'port': fields.Integer, 33 | 'webport': fields.Integer, 34 | 'apitoken': fields.String, 35 | 'freqband': fields.String, 36 | 'netid': fields.Integer, 37 | 'duplicateperiod': fields.Integer, 38 | 'fcrelaxed': fields.Boolean, 39 | 'otaastart': fields.Integer, 40 | 'otaaend': fields.Integer, 41 | 'macqueueing': fields.Boolean, 42 | 'macqueuelimit': fields.Integer, 43 | 'adrenable': fields.Boolean, 44 | 'adrmargin': fields.Float, 45 | 'adrcycletime': fields.Integer, 46 | 'adrmessagetime': fields.Integer, 47 | } 48 | self.parser = reqparse.RequestParser(bundle_errors=True) 49 | self.parser.add_argument('name', type=str) 50 | self.parser.add_argument('listen', type=str) 51 | self.parser.add_argument('port', type=int) 52 | self.parser.add_argument('webport', type=int) 53 | self.parser.add_argument('apitoken', type=str) 54 | self.parser.add_argument('freqband', type=str) 55 | self.parser.add_argument('netid', type=int) 56 | self.parser.add_argument('duplicateperiod', type=int) 57 | self.parser.add_argument('fcrelaxed', type=bool) 58 | self.parser.add_argument('otaastart', type=int) 59 | self.parser.add_argument('otaaend', type=int) 60 | self.parser.add_argument('macqueuing', type=bool) 61 | self.parser.add_argument('macqueuelimit', type=int) 62 | self.parser.add_argument('adrenable', type=bool) 63 | self.parser.add_argument('adrmargin', type=float) 64 | self.parser.add_argument('adrcycletime', type=int) 65 | self.parser.add_argument('adrmessagetime', type=int) 66 | self.args = self.parser.parse_args() 67 | 68 | @login_required 69 | @wait_for(timeout=TIMEOUT) 70 | @inlineCallbacks 71 | def get(self): 72 | """Method to handle system configuration GET requests""" 73 | try: 74 | config = yield Config.find(limit=1) 75 | # Return a 404 if not found. 76 | if config is None: 77 | abort(404, message={'error': "Could not get the system configuration"}) 78 | returnValue(marshal(config, self.fields)) 79 | 80 | except TimeoutError: 81 | log.error("REST API timeout retrieving application {appeui}", 82 | appeui=euiString(appeui)) 83 | 84 | @login_required 85 | @wait_for(timeout=TIMEOUT) 86 | @inlineCallbacks 87 | def put(self): 88 | """Method to handle system configuration PUT requests 89 | 90 | Args: 91 | appeui (int): Application EUI 92 | """ 93 | try: 94 | config = yield Config.find(limit=1) 95 | # Return a 404 if not found. 96 | if config is None: 97 | abort(404, message={'error': "Could not get the system configuration"}) 98 | 99 | # Filter args 100 | params = {k: v for k, v in self.args.iteritems() if v is not None} 101 | 102 | # Set the new attributes 103 | for a,v in params.items(): 104 | setattr(config, a, v) 105 | 106 | # Validate the configuration 107 | (valid, message) = config.check() 108 | if not valid: 109 | abort(400, message=message) 110 | 111 | # Reload the config 112 | (success, message) = self.server.reload(config) 113 | if not success: 114 | abort(500, message=message) 115 | 116 | yield config.save() 117 | returnValue(({}, 200)) 118 | 119 | except TimeoutError: 120 | log.error("REST API timeout retrieving application {appeui}", 121 | appeui=euiString(appeui)) 122 | 123 | 124 | except TimeoutError: 125 | log.error("REST API timeout retrieving application {appeui}", 126 | appeui=euiString(appeui)) 127 | 128 | -------------------------------------------------------------------------------- /floranet/web/webserver.py: -------------------------------------------------------------------------------- 1 | from twisted.internet import reactor 2 | from twisted.web.wsgi import WSGIResource 3 | from twisted.web.server import Site 4 | from twisted.internet.defer import inlineCallbacks, returnValue 5 | 6 | from flask import Flask 7 | from flask_login import LoginManager, UserMixin 8 | from crochet import no_setup 9 | 10 | from floranet.web.rest.restapi import RestApi 11 | from floranet.log import log 12 | 13 | no_setup() 14 | 15 | class WebServer(object): 16 | """Webserver class. 17 | 18 | Attributes: 19 | app (Flask): Flask app instance 20 | site (Site): Twisted web site 21 | port (Port): Twisted UDP port 22 | api (RestApi): Rest API instance 23 | login (LoginManager): Flask login manager 24 | """ 25 | def __init__(self, server): 26 | """Initialize the web server. 27 | 28 | Args: 29 | server: NetServer object 30 | """ 31 | self.server = server 32 | self.port = None 33 | 34 | # Create Flask app and configure 35 | self.app = Flask(__name__) 36 | self.app.config['ERROR_404_HELP'] = False 37 | 38 | # Create REST API instance 39 | self.restapi = RestApi(self.app, self.server) 40 | 41 | # Create LoginManager instance and configure 42 | self.login = LoginManager() 43 | self.login.init_app(self.app) 44 | self.login.request_loader(self.load_user) 45 | 46 | def start(self): 47 | """Start the Web Server """ 48 | self.site = Site(WSGIResource(reactor, reactor.getThreadPool(), self.app)) 49 | self.port = reactor.listenTCP(self.server.config.webport, self.site) 50 | 51 | @inlineCallbacks 52 | def restart(self): 53 | """Restart the web server""" 54 | yield self.port.stopListening() 55 | self.port = reactor.listenTCP(self.server.config.webport, self.site) 56 | 57 | def load_user(self, request): 58 | """Flask login request_loader callback. 59 | 60 | The expected behavior is to return a User instance if 61 | the provided credentials are valid, and return None 62 | otherwise. 63 | 64 | Args: 65 | request (request): Flask request object 66 | 67 | Returns: 68 | User object on success, otherwise None 69 | """ 70 | # Get the token as Authorisation header or JSON parameter 71 | token = request.headers.get('Authorization') 72 | if token is None: 73 | data = request.get_json() 74 | if data is None: 75 | return None 76 | token = data['token'] if 'token' in data else None 77 | 78 | # Verify the token 79 | if token is not None: 80 | if token.encode('utf8') == self.server.config.apitoken: 81 | return User('api', None) 82 | 83 | return None 84 | 85 | class User(UserMixin): 86 | """Proxy class to return for token verification""" 87 | 88 | def __init__(self, username, password): 89 | self.id = username 90 | self.password = password 91 | 92 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | setup(name='floranet', 6 | version = '0.4.8', 7 | description = 'LoRa Network Server', 8 | author = 'Frank Edwards', 9 | author_email = 'frank.edwards@fluentnetworks.com.au', 10 | url = 'https://github.com/fluentnetworks/floranet', 11 | packages = find_packages(exclude=["*.test", "*.test.*"]), 12 | install_requires = ['twisted==18.4.0', 'psycopg2>=1.6', 13 | 'pyOpenSSL>=18.0.0', 'pyasn1>=0.4.3', 14 | 'service-identity>=17.0.0', 15 | 'twisted-mqtt>=0.3.6', 16 | 'twistar>=1.6', 'alembic>=0.8.8', 17 | 'py2-ipaddress>=3.4.1', 18 | 'pycrypto>=2.6.1', 'CryptoPlus==1.0', 19 | 'requests>=2.13.0', 'flask>=0.12', 20 | 'Flask-RESTful>=0.3.5', 'Flask-Login>=0.4.0', 21 | 'crochet>=1.6.0', 'click>=6.7', 'click_shell>=1.0', 22 | 'mock>=2.0.0'], 23 | dependency_links = ['https://github.com/doegox/python-cryptoplus/tarball/master#egg=CryptoPlus-1.0'], 24 | scripts = ['cmd/floranet', 'cmd/floracmd'], 25 | ) 26 | 27 | --------------------------------------------------------------------------------