This project was made by the "Project RIOT" - Group from the University of Applied Science in Hamburg, Germany (HAW Hamburg). Visit the INET website to get in contact with us.
Our main idea was to set up a network infrastructure that would serve as a back-up network, in case of a disaster that could knock out our main connectivity systems.
To prove that our small balloons can also provide communication service, we built a working prototype that is able to send and receive data, connect with the cloud and execute user commands.
In addition, we equipped our MiniLoons to measure environmental data and build a Smartphone App to display the gathered information.
Our motivation, project description and a showcase of the working prototype is further presented in this video.
We divided our team in three different groups:
Bollon-Group: First Part (responsible for the Balloon)
Smartphone-App-Group: Second Part (responsible for the App)
Gateway-Group: Third Part (responsible for the communication between App and Balloon)
First Part: Building the BalloonLets start with building and implementing the hardware on the balloon. All the code and the schematics are in the balloon repository linked at the end under code.
Step 1.1: Creating a custom PCBMotivation
Weight:
Our balloons are restricted in the way, that they can only lift 30-40g. But our prototype on which we connected all the different boards with cables etc. already weights 55g. And this does not include the battery, the valve that we use for letting the balloon sink and optionally the second valve for letting the balloon rise or any of the 3D printed parts that we use to connect our board and the valves to the balloon.
With our custom PCB we wouldn’t need any cables, and we also could make our final board even smaller and thus lighter than the original esp32 devkit board from our prototype.
With all the sensor, pin headers and other electrical components soldered on (without the battery), we get a total weight of: 10.7g
Saved weight in comparison to the prototype (without the battery): 44.3g
What is KiCad
KiCad is a free, open source and cross platform suite for electronic design automation (EDA). It gives us a hand of very easy to use tools to create our own electric circuit (schematic) and then our own PCB (a custom board). We only used the tools shown below.
(a) Symbol Editor: Is used to create our own custom symbols for unusual components, that are not part of the standard KiCad symbol library. A symbol defines, how many in- and outputs our component has and what these are used for.
(b) Footprint Editor: Is used to create footprints for our custom symbols. Footprints define, how a symbol is structured on the physical layer.
(c) Schematic Layout Editor: Is used to create our own electric circuit, which defines how all our symbols are connected to each other.
(d) PCB Layout Editor: Is used to create our PCB layout, by connecting all the footprints the way we specified on the schematic, but on the physical layer.
Design the schematic
First of all, we had to design the schematic that tells the program, which components are connected with other components through which pins. Therefore, for each component you want to use on your board, you need a symbol that represents it and its connectable pins. Components like Diodes or Mosfets are often already designed or can be represented by different Mosfets because sometimes only the shape and the amount of pins matter.
Components that are not simple electrical parts but as advanced as a microcontroller like the ESP32, are for sure more specific. But because of its popularity the ESP32 can be found in internal integrated libraries of KiCad or on free, online libraries or via websites like SnapEDA. The next picture shows the schematic symbol of the ESP32-WROOM with all its connectable pins.
More Specific modules like the DRF1272F, our LoRa module, sometimes can’t be found on the internet, or for some reasons you want to change something on a symbol, you have to design the symbol on your own. For these cases, use the KiCad Symbol Editor. There are many Tutorials online on how to design symbols and how to use the symbol editor.
When you have all your symbols, don’t connect them just using the wire tool, your schematic gets messy and finding mistakes becomes very hard. Use global labels instead. Only use wires if you have small easy circuits on your board that are easy to understand like the one in above. When you are done labelling and connecting the different components, it’s time for the next step, the footprints.
Footprints
The Symbols might at the first sight look like the real electronic components, at least from their shape. But the symbols in the schematic are only placeholders of the components and only represents the amount and naming of connectable pins. Each symbol has a footprint that represents their real shape and their real position of all pins, also those which are not shown on the symbol. Even though the symbol is only a placeholder, numbering should be in sync with the footprint.
It’s convenient if the pin labelling on the symbol says more than the pin number. Here an example: GND for pin 1 in the first picture of “Design the schematic “. The next picture is the actual footprint of the ESP32-WROOM. The other one is its symbol placeholder.
Footprints are essential for the PCB Layout Editor, more on that in the next section. But before that, how do we get the footprints? As well as the symbols, you can use the footprints from internal/external libraries and also e.g. SnapEDA sometimes offers footprints. But especially if you are not sure that the footprints are legit, there is no other way than creating your own footprint by using the Footprint Editor which GUI logo is shown earlier.
Because measuring the real component is hard, most datasheets come with footprints or PCB land pattern of their product. The picture shows the PCB land pattern of the ESP32-WROOM from its datasheet.
PCB Layout Editor
Now it is getting serious, we are designing the PCB Layout.
At first before you start designing, already look for Design rules of the manufacturer of your choice. We chose OshPark as our manufacturer, they already offer Design rules and Net Classes rules dedicated to KiCad. Some manufacturers offer their rules only in imperial units, for that just switch KiCads view from metric to imperial units. You can switch back to metric units after the board setup, KiCad converts the units automatically.
After you populated the rules into your board’s setup, you might decide on how many layers you want to use. We decided to use only 2 Layers, with components on both sides of the board, therefore we don’t have a layer only dedicated as a ground plane.
After that, check your components datasheets again carefully if they have PCB layout suggestions! For example, in the footprint section there is the picture of the ESP32 footprint. There you can see that the footprint designers already thought of a keep-out-zone for the antenna. But again, you can’t always trust footprints from external libraries, therefore read carefully!
After that you now need to import the components of your schematic into your PCB Layout Editor, for that use the "Update PCB from schematic" tool in the PCB Layout Editor. This will annotate component and look if all components have footprints assigned to them, if so, no error will be shown.
The picture below shows all imported components.
To make things easy for your wiring/-tracking and positioning of the components, enable the "Show board ratsnest" which draws lines that show which pins have to be connected based on your schematic.
Now you can start positioning the components. Again, we put components on both sides and many on the edge of the board as you can see after we defined the edge cuts.
After the positioning you can start connecting the pins with tracks/wires. To go from one layer to another use vias. A little hint, you can put the vias directly on to tracks to automatically assign a net class to the via, or you assign a net class separately. KiCad already ensure that you can cross wires on the same layer or go through drilled holes.
After you connected everything, draw the board edge of the PCB, here in yellow. Now you can see that certain components hang over the edge of the board. We are doing this to again reduce the weight of the board. After that you run the Design Rules Check. This tools logo is a red ladybird. After you have run the DRC it should show 0 problems and 0 unconnected items.
To further be sure that the board looks like you want it to look, go to the 3D Viewer and concentrate on the orientation of the components. If you have added labels for the front/back of the board also inspect your labels on the Silkscreen in the 3D Viewer. We have also added our projects logo which can be added by using the "Bitmap to Component Converter" to import .svg
files as components.
Finally you can create gerber
files to send those to your manufacturer or use the ".kicad_pcb
" files instead, if your PCB manufacturer supports those like OshPark does.
Soldered Board
To connect the valves, our custom board, and the balloon, we created a 3D model that has one hole in the centre for the valve that lets new gas flow from a bottle into the balloon. It is surrounded by a pipe like structure which is used to screw the bottle to the balloon. The hole for the other valve, which is used to let gas out of the balloon is outside of this pipe. We also added two holes for the cables of the valves.
To connect the board, we added some sort of anchor on which we could hang up our board and glue it, so it does not fall of.
We also created a second version with only one hole for the valve that lets the balloon sink, as we weren’t sure, whether the second valve which was used to fill in new gas from a bottle to the balloon would work out after all.
To create the 3D model, we used Blender and exported it to an .stl
file which can be read by Cura. Cura is used to create an .gcode
file that tells the 3D printer what to do. The .gcode
file also contains the print settings like the Layer height, the nozzle temperature etc.
Unfortunate we didn’t have enough time to implement the function of the valve.
Step 1.3: Get GPS DataConnect the Hardware
The goal of this part is to sense the current position in longitude/latitude coordinates and to get the current local time and date.
For that we started to read the datasheet of the Quectel L96 and the esp32 to know which bus system and which pins we could use to connect the module to the esp. While the module can use the I2C and the UART Interfaces, we decided to use the UART Interface because we’ve never worked with that before and the esp is also able to send/receive data with both.
In the first picture "Connection of Serial Interfaces" we can see that we have to connect the RX pin of the module to the TX pin of the esp and vice versa for the other direction, because with the ESP RXD pin we receive data and the ESP TXD pin we send data. To achieve that we are connecting the esp GPIO 16 (RXD) to the modules GPIO25 (TXD) and the esp GPIO17 (TXD) to the modules GPIO26 (RXD). (We can’t use the TXD0 and RXDO pins of the ESP32 because its logging over these pins.)
To power the module, we also have to connect the 3V3 pin of the esp32 to the VCC pin of the module and the GND pins with each other.
Write the program
To make things easy we are using libraries and drivers from RIOT. We are using a UART peripheral driver interface to read/write data from/to the UART bus and a GPS parser library that translates the standardized NMEA character sentences to more handy data types.
To start we defined the baudrate for the connection and which UART device we are using. We use DEV(1) because we didn’t connect the module to the TXD0/RXD0 of the esp.
# include " minmea .h"
# include " periph / uart .h"
# define BAUDRATE (9600 U)
# define DEV UART_DEV (1)
static kernel_pid_t main_thread_pid ;
enum types {rmc , gga , gsa , gll , gst , gsv , vtg , zda };
static char strTypes [8][4] =
{"RMC ", " GGA", "GSA", "GLL", "GST", "GSV ", "VTG", "ZDA"};
After that, you should initialize the UART device and handover a reference to a function, here rx_cb, that is called by the UART RX interrupt when data arrives.
void initGPSData ( void ) {
main_thread_pid = thread_getpid ();
uart_init (DEV , BAUDRATE , rx_cb , ( void *) DEV);
}
The rx_cb function is called for every byte that arrives and sends these bytes via message passing to the main_thread which should be waiting to receive data.
static void rx_cb ( void *uart , uint8_t c) {
msg_t msg;
msg . type = (int) uart ;
msg . content . value = ( uint32_t )c;
msg_send (& msg , main_thread_pid );
}
Here, GetGPSData is run by the thread with main_thread_id as its threadID. The while loop goes on as long as not all 3 NMEA sentences have been received.
struct gps_data getGPSData ( void ) {
msg_t msg;
char buff [100] = {0};
char sentences [3][100] = {0}; // {{ rmc}, {gll}, {vtg }}
int i = 0;
bool lock [3] = {true , true , true }; // {rmc , gll , vtg}
while ( lock [0] || lock [1] || lock [2]) {
msg_receive (& msg);
if (((( char )msg. content . value ) == ’$’)) {
if ( strstr (buff , strTypes [rmc ])) {
buff [i] = ’\0 ’;
memcpy ( sentences [0] , buff , 100) ;
lock [0] = false ;
}
else if ( strstr (buff , strTypes [gll ])) {
buff [i] = ’\0 ’;
memcpy ( sentences [1] , buff , 100) ;
lock [1] = false ;
}
else if ( strstr (buff , strTypes [vtg ])) {
buff [i] = ’\0 ’;
memcpy ( sentences [2] , buff , 100) ;
lock [2] = false ;
}
memset (buff , 0, 100) ;
i = 0;
}
buff [i] = ( char )msg. content . value ;
i++;
}
When NMEA sentences for all three types have been send, like the one after the next code section below, each buffer is filled with one NMEA sentence which now can be further translated with the Minmea parser library. This is shown below with the use of "minmea_parse".
struct gps_data data = {0};
struct minmea_sentence_rmc srmc ;
minmea_parse_rmc (& srmc , sentences [0]) ; // rmc sentence
data . date .d = ( uint8_t ) srmc . date .day ;
data . date .m = ( uint8_t ) srmc . date . month ;
data . date .y = ( uint8_t ) srmc . date . year ;
data . time . hour = ( uint8_t ) srmc . time . hours ;
data . time .min= ( uint8_t ) srmc . time . minutes ;
data . time .sec= ( uint8_t ) srmc . time . seconds ;
data . time .mic= ( uint8_t ) srmc . time . microseconds ;
struct minmea_sentence_gll sgll ;
minmea_parse_gll (& sgll , sentences [1]) ; // gll sentence
data .gps.lng = minmea_tocoord (& sgll . longitude );
data .gps.lat = minmea_tocoord (& sgll . latitude );
struct minmea_sentence_vtg svtg ;
minmea_parse_vtg (& svtg , sentences [2]) ; // vtg sentence
data .gps.vel = minmea_tofloat (& svtg . speed_kph );
(...)
}
There are many different NMEA sentences. We are using the "rmc" one for getting the time and date, "gll" one for the longitude and latitude position coordinate and the "vtg" one for the modules current speed (As far as I can tell the speed demodulation isn’t that precise to be honest). For further knowledge on NMEA types, just visit this website. NMEA Sentence:
$GPRMC ,162614 ,A ,5230.5900 ,N ,01322.3900 ,E ,10.0 ,90.0 ,131006 ,1.2 ,E,A*13
Testing and the convenience of a shell
After you have initialized the UART device with initGPSData(), you can call getGPSData() at best on an struct gps_data variable to further use the data. For testing purposes its convenient to add the following to the end of the getGPSData() function.
printf (" -------Date -------\n");
printf ("day: %d, month : %d, year %d\n\n",
data . date .d, data . date .m, data . date .y);
printf (" -------Time -------\n");
printf (" hour : %d, min: %d, sec: %d, microsec : %d\n\n",
data . time .hour , data . time .min , data . time .sec , data . time .mic);
printf (" -------GPS -------\n");
printf (" long : %f, lat: %f, speed %f\n\n",
data .gps.lng , data .gps.lat , data .gps .vel);
Moreover, to get data at the flick of a command... switch, it’s convenient to use the RIOT shell interface, so you don’t have to flash or reset the esp to rerun the code, you just need to run the "gps_data" command another time in the shell.
int gps_data (int argc , char ** argv ) {
getGPSData ();
( void ) argc ;
( void ) argv ;
return 0;
}
static const shell_command_t commands [] = {
{ " gps_data ", " Prints some gps values ", gps_data },
{ NULL , NULL , NULL }
};
int main ( void ) {
initGPSData ();
char line_buf [ SHELL_DEFAULT_BUFSIZE ];
shell_run ( commands , line_buf , SHELL_DEFAULT_BUFSIZE );
return 0;
}
Step 1.4: Flashing the ESP with our applicationTo Flash the board, connect the esp32 to your pc and just run the following command in the terminal which has to be at the projects folder. (Maybe with superuser rights)
make BUILD_IN_DOCKER =1 BOARD =esp32 -wroom -32 make all term flash
BUILD_IN_DOCKER: If its value is set to 1, Docker is used to include the toolchain, necessary for building RIOT on the esp32. This makes things very easy, as otherwise we would have to setup the esp32 toolchain etc. on our local machine. Which can be very time consuming.
BOARD: Specifies the board, which we are using. In this case its the esp32-wroom-32, which is the most common esp32 board. But there are many other boards available in RIOT. This gives us the ability to flash our application on whatever board we want, without making any code changes.
make all: Specifies, that we want to compile all files within the current directory.
term: This will open a python terminal, after the code was compiled. This gives us the ability to send commands to the board. For example, we could write "ifconfig" to get the devices IP address
.
flash: If we write "flash" at the end of our command, our program will be flashed and executed on our board after it is compiled. Otherwise our code would be executed on our local machine.
Step 1.5: Setup the LoRa modulFor our use-case we decided to use the drf1272f LoRa modul, which uses the semtec 1272 sensor. This board is very small, and we could easily attach an even smaller antenna onto it.
Connect the Hardware
The sensor communicates over SPI and some additional io pins. We connected them all with the esp32 like this:
Configure the Software
RIOT OS already includes a driver for the semtec 1272 sensor, so we only have to change a few defines to let it know, which ports of the esp32 we used to connect to the ports of the sensor like in the table above:
# define SX127X_PARAM_SPI ( SPI_DEV (1))
# define SX127X_PARAM_SPI_NSS GPIO15
# define SX127X_PARAM_RESET GPIO32
# define SX127X_PARAM_DIO0 GPIO33
# define SX127X_PARAM_DIO1 GPIO25
# define SX127X_PARAM_DIO2 GPIO26
# define SX127X_PARAM_DIO3 GPIO27
SX127X_PARAM_SPI
specifies that the SPI device 1 of the esp_wroom_32 board is used. This is configured to be HSPI in the board configuration in RIOT.
Now there is a problem, that if we specify these defines inside our codebase, we would not be able to flash our code on a different board, that already has the LoRa modul build in, but connected to other ports. That is why we decided to create our custom "mini-loon
" board implementation in RIOT. Therefor we forked RIOT and added our new board in the "boards" directory. Our new board basically is only a clone of the "esp32-wroom-32" board, but in addition it also contains our new LoRa defines. From now on whenever we flash our application we write:
make BUILD_IN_DOCKER =1 BOARD =mini - loon make all term flash
Step 1.6: MeasurementNow we implement the measurement of temperature, air pressure and humidity.
For that we started reading the documentation of the SAUL-Interface of RIOT, which allows a simple and generic reading of connected sensors. Helpful was one of the example programs for RIOT that demonstrated how the SAUL interface is to be used. After successfully reading the sensor values with SAUL we used the Esp32-wroom-32 Board on which our custom Mini-Loon Board that we have attached to the Balloon is based on and connected the Bosch BME280 sensor to it.
A problem with the reading of the air pressure was that for computing the height of the balloon based on the difference in air pressure on the balloon to the air pressure on the ground the SAUL-Interface doesn't return the air pressure with a high enough resolution.
This means that air pressure is only displayed in hectopascals without decimal places which is not suitable for our calculations. For now, we just bypass the SAUL-Interface in that case and read directly from the sensor which gives us the resolution we need. Downside of this is that for now our application only works with the BME280 sensor for measuring the air pressure and not with any kind of air pressure sensor.
EXAMPLE: Registration of SAUL-Resource and measurement:
saul_reg_t *devTemp = saul_reg ;
phydat_t res ;
devTemp = saul_reg_find_type(SAUL_SENSE_TEMP) ;
saul_reg_read(devTemp, &res) ;
//Now the temperature value is stored in the res variable.
Step 1.7: CoAP CommunicationThe goal was to send the temperature, air pressure and humidity sensor values to the gateway via CoAP. CoAP is a lightweight web-transfer-protocol especially for IoT-devices so it is very suitable for our needs. We started working with example code of RIOT for using CoAP to see that our different devices can communicate with each other. After that we could just expand the example code for CoAP so that when a device sends a request for a specific sensor value, the reading of this sensor is triggered, and the sensor value is returned to the other device.
Step 1.8: LoRa-WAN CommunicationNow we want to establish Communication between balloon and gateway via LoRaWAN. This allows for a very long-range communication between the balloon and the gateway. We start with creating an application on the TTN and registering the b-l072z-lrwan1 Board as a device for our application. This board has a built-in LoRa-module so we could just start working with it.
To send the data to the gateway simply periodically send all our different sensor values in the form of the map in the CBOR-format to the network. The period of sending the data should be chosen rather long for longer battery life and to avoid duty-cycle-restrictions.
EXAMPLE: LoRa setup, join, send without error-handling.:
// Initialize the LoRaMAC MAC layer
semtech_loramac_init(&loramac);
// Set the keys identifying the device (these are variables we can
not show you)
semtech_loramac_set_deveui(&loramac, deveui);
semtech_loramac_set_appeui(&loramac, appeui);
semtech_loramac_set_appkey(&loramac, appkey);
// Join procedure to register device on TTN
semtech_loramac_join(&loramac, LORAMAC_JOIN_OTAA)
// Sending MSG to TTN
semtech_loramac_send(&loramac, MSG, MSG_LENGTH);
Step 1.9: CBOROne of the reasons we use CBOR are the already mentioned duty- cycle-restrictions of TTN. You are only allowed to send X bytes of data every Y second. These values differ depending on the chosen spreading factor of the application so no explicit values can be given. Instead of sending strings, integers or float values all data is hex-base encoded before we send it. Afterwards the gateway decodes the data back to their original form. Sadly, the CBOR documentation is a bit lacklustre and we needed to use the advanced method of "trial-and-error" to get it to work. First of all, an encoder needs to be initialised with a buffer to write to. This buffer is then filled with the cbor-hex-code for "map-start". Afterwards pairs of keys and values are encoded and added to the buffer. When everything is added the map is closed.
uint8_t buf [BUFSIZE] = {0};
CborEncoder encoder, mapEncoder;
size_t length = CborIndefiniteLength;
cbor_encoder_init(&encoder, buf, sizeof(buf), 0);
cbor_encoder_create_map(&encoder, &mapEncoder, length);
saul_reg_read(devTemp, &res);
addFloatToMap("temp" , ((float) res.val[0]) / 100.0, &mapEncoder);
[...other pairs can be added...]
cbor_encoder_close_container_checked(&encoder, &mapEncoder);
NOTE: For simplicity this example uses a list instead of a map. The List of Strings ["temp", "pres", "hum"] can be encoded in just 15 Bytes. Which looks like this:
83 // List of 3−Elements
64 // String of 4−signs
74 65 6D 70 //"temp"
64 // String of 4−signs
70 72 65 73 //" pres "
63 // String of 3−signs
68 75 6D //"hum"
As you can see the readability gets reduced a bit, but some interesting things can still be seen: First, the data is structured into different layers. On the first layer we have a hex-value of "80" which indicates a list. This value is increased by 3 to show that the list contains three elements. A list of 6 elements would be indicated by "86". The next layers first byte is "64". The decoder knows that he started with a list so this must be interpreted as a type. Equivalent to the list being "80" the string datatype is represented by "60" which is incremented by 4 to show that the string is 4 signs long. Now the decoder knows that the four bytes on the third layer must be the representing the string. This leads to the fourth byte "65” not being interpreted as a string with 5 signs but as the letter "e". When the decoder has read the third byte for the third element he is finished and stops reading, even if you provide more bytes.
Step 1.10: State signalling with LED'sWe decided to include two LED's onto our board for two different Reasons. First it shows that something is actually happening on the board and not only on TTN. It also made our testing easier as we could get feedback from the board in real life.
After the balloon is set up we continue with the App.
In this part is fully written on Flutter, with additional packages that we get from pub.dev (the official website to get Dart/Flutter packages), like the Firebase Database package, which we use to get the values we needed from the database.
The IDEs we used were IntelliJ IDEA and Visual Studio Code, which are naturally personal preference.
We also used Photoshop to create or manipulate assets used in the app.
Step 2.1: Set up of the app projectFirst we have to set up a Flutter project. The way to do this is to follow the great tutorial made by Google on how to get started. You can find how to install it OSes here.
After having all the set up done we were ready to start coding. We recommend to start reading up on Flutter’s documentation and some others (StackOverflow, YouTube, etc.) to be somewhat familiar with the language in early stages.
Step 2.2: Set up of the Firebase Realtime DatabaseTo set up a Realtime Database from Firebase, you first need to create a Firebase project:
1. In the Firebase console, click ‘Add Project’ and give it a name
2. Continue to set up Google Analytics if you want/need them
3. Click ‘Create Project’.
We don’t need the analytics, so you do not set Google Analytics. You then need to register your app with Firebase:
1. Go to the Firebase console
2. Click the Android icon (or add app) of your project
3. Type the App’s package name in the appropriate field (found in app/build.gradle
). Take note that it is case sensitive
4. Optionally give it a nickname and a debug signing certificate SHA-1
5. Click ‘Register app’
The next step is to add a Firebase config file in your project:
1. Click ‘Download google-services.json’ to download your config file (in the project settings)
2. Move your config file into the app-level directory of your app. For flutter apps, this should be android/app
.
3. You need to update 2 gradle files:
a. In your project-level (android/) add a rule to include the Google Services Gradle plugin in the build.gradle
file:
dependencies {
// ...
// Add the following line:
classpath 'com.google.gms:google-services:4.3.4' // Google Services plugin
}
b. In your app-level (android/app/) apply the plugin
:
apply plugin: 'com.android.application'
// Add the following line:
apply plugin: 'com.google.gms.google-services' // Google Services plugin
4. Run flutter packages get on your terminal to apply these changes and download any packages you haven’t.
5. You can then add FlutterFire plugins. We needed firebase_database because it’s the one that deals with the Realtime Database.
a. You then need to add it to your pubspec.yaml
file, listing the new plugin in the dependencies, as you do for all packages you install.
6. Run flutter packages get again to download needed files.
The whole tutorial is also available here, in the official Firebase documentation website.
The google-services.json
file you download has your specific database configuration, which is how your app will know where to download the data from, and the parameters in this file can be used in different services of your app. For example, the firebase_url parameter was also used by the gateway to access the database, to be able to write to it the data we would fetch.
To add different collaborators to the project, you need to go to 'Users and Permissions' on your settings and click ‘Add Members’ where you can add members by e-mail and give them a certain role.
The structure for the database is then created by the Gateway in the third part of this project.
Step 2.3: Home Page ViewAfter setting up the App you can now start with the building of the Home Page.
We decided to use blue-ish tones as a representation of the sky. These are the exact hex values we used:
· #2E8BC0 – For the app bar
· #0C2D48 – For the text
· #B1D4E0 – For the cards
But you can choose your own colours.
To implement the home page view, we use a Scaffold Widget, because it has parameters that fill up the whole screen like an AppBar and a Body which allows you to set the style like you want it. This is our example:
Scaffold(
appBar: PreferredSize(
preferredSize: Size.fromHeight(
MediaQuery.of(context).size.height * 0.15), //Adaptive height
child: AppBar(
backgroundColor: Color(0xFF2E8BC0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15)),
flexibleSpace: Container(
child: Image.asset(
'assets/images/miniloon.png',
),
//padding: const EdgeInsets.all(30),
padding: const EdgeInsets.only(
bottom: 20, top: 30, left: 30, right: 30),
)
)
),
body: Container(…)
With these set parameters, we have a dynamic size for the AppBar on top – at 15%. This may look “odd” as the call for the AppBar comes later than you would think it should. This is where Flutter shines. The child widget of the preferredSize (which is what lets us have dynamic size) is the AppBar widget, and this way there’s no errors, as in the end, the AppBar is still being built on the right place.
After having the view, we implement cards to show each of the real-time parameters. To do that, we use the Container and BoxDecoration Widgets:
Container(
decoration: new BoxDecoration(
color: Color(0xFFB1D4E0),
borderRadius: new BorderRadius.circular(15)),
padding: const EdgeInsets.all(8),
child: Center(
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: <TextSpan>[
TextSpan(
text: title + '\n\n',
style: TextStyle(
fontFamily: "Roboto",
fontWeight: FontWeight.bold,
fontSize: 20,
color: Color(0xFF0C2D48)
)
),
TextSpan(
text: "$valueS$measure",
style: TextStyle(
fontFamily: "Roboto",
fontWeight: FontWeight.bold,
fontSize: 25,
color: Color(0xFF0C2D48)
),
)
]
)
),
),
)
As you can see, the BoxDecoration allowed us to set the exact colour we wanted, as well as adding a border radius to have that rounded look and a padding.
To display the values in real-time we use a FutureBuilder, because we need to get the values from the database, and it allows us to display something different while the data isn’t fetched. We use a circular progress indicator, similar to those you see on most modern apps. This looks like this:
FutureBuilder(
future: ref.child(locationInDb1).child(locationInDb2).child(locationInDb2).once(),
builder: (context, AsyncSnapshot<DataSnapshot> snapshot) {
if (snapshot.hasData) {
value = snapshot.data.value;
valueS = value.toStringAsFixed(2);
return new GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => chooseGraph(title)
)
);
},
(…)
return CircularProgressIndicator();
Between the above snippet and the last line is just the code you’ve seen before to setup the cards.
Since there isn’t a built-in simple enough method to cut a double to 2 decimal points, we convert it to a string with 2 decimal points.
Result:
After we have implemented the Home View it's time to get some values from the database and show them on the App.
To do this we use the connection to the database to get the latest values on several parameters, as well as information for the maps, and the charts which will be implemented later. To connect to the database, we use the available firebase_database package. The database is formatted as a JSON file, and both Firebase and Flutter are Google products, so it is easier to work with these two as our main tech stack.
Don’t forget to initialize the database in the main method:
void main() async {
(…)
await Firebase.initializeApp();
(…)
}
After this, you need to make a reference to the spot in the database in your Flutter application on your initState() override in your HomePageState class. This is how you do that:
final ref = FirebaseDatabase.instance.reference();
After this you access the different points of the database with the .child(‘placeInDatabase’) method. There are also Queries you can make like limiting to the last value, or to access that point when something is edited, removed, or added in the database. After querying, you can use the .once().then() and do what you need with that information. Here’s the example for when we access the database to get the latest value of a parameter:
return FutureBuilder(
future:
ref.child(locationInDb1).child(locationInDb2).child(locationInDb2).once()
builder: (context, AsyncSnapshot<DataSnapshot> snapshot) {
if (snapshot.hasData) {
value = snapshot.data.value;
valueS = value.toStringAsFixed(2);
return new GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => chooseGraph(title)
)
);
},
(…)
Since we use the FutureBuilder, we do not need the .then() method, but the concept is the same. If the snapshot we get from the database has data, we set the variables we need to show them later.
Step 2. 5: Map and Cluster integrationTo display in a geographical manner, we use the google_maps_flutter package, we create the widget GoogleMap inside the body of the app.
child: GoogleMap(
mapType: MapType.terrain,
initialCameraPosition: CameraPosition(target: _initialLatLng, zoom: 10.5),
onMapCreated: (GoogleMapController controller) {
_controller.complete(controller);
_getCircles();
},
compassEnabled: true,
tiltGesturesEnabled: false,
onTap: (latLng) {},
onLongPress: (latLng) {},
markers: Set<Marker>.of(_clustersMarkers.values),
circles: Set<Circle>.of(_clustersCircles.values),
)
Because the map takes some seconds to load and to pick up data to render in order to avoid displaying an error message we create an condition to inform the user that the map is loading by verifying the value of the variable _initialLatLng that contains the information of the position of Google Maps initial CameraPosition.
child: _initialLatLng == null ? Container(
child: Center(
child: Text(
'LOADING MAP...',
style: TextStyle(fontFamily: 'Roboto', color: Colors.white, fontSize: 25),
),
),
)
To generate the circles when the map was created, we need a function in _MyHomePageState. This function will start when the map is initiated and will pick the information about the clusters from the database and create the circles.
_getCircles() {
setState(() {
ref.child("clusters").once().then((DataSnapshot data) {
Map clusterMap = data.value;
CircleId circleId;
clusterMap.entries.forEach(
(element) {
Map map = element.value;
circleId = CircleId(map["id"].toString());
_addClusterCircle(LatLng(map["mapX"], map["mapY"]), circleId);
}
);
});
});
}
To allow the user to select one cluster and by doing that automatically unselecting the already active map we assigned a function to the onTap attribute of the Circle when creating it.
Circle circle = Circle(
circleId: circleId,
center: center,
radius: 800,
fillColor: Colors.cyan.withOpacity(0.3),
strokeWidth: 3,
consumeTapEvents: true,
onTap: () {
_changeMarkerAndCircleType(center, circleId, _activeCircle);
}
);
In order to make this feature possible we need to identify the circles in a Map<CircleId, Circle>. This way we are able to identify the active cluster and update it when needed and update the new cluster to be the active one.
Map<MarkerId, Marker> _clustersMarkers = <MarkerId, Marker>{};
Map<CircleId, Circle> _clustersCircles = <CircleId, Circle>{};
MarkerId _activeMarker;
CircleId _activeCircle;
LatLng _initialLatLng;
Step 2.6: ChartsTo plot charts with the latest values of a parameter from the database, we use the charts_flutter package, which let us somewhat easily plot the charts with the data we wanted. We use the Stateful Widget as opposed to the Stateless, which is much easier to mutate if/when necessary even though it takes some work to get it up and running in the early stages. It also allows you to use the initState() and dispose() functions, which might be handy. We recommend basing the build() method of the view to be similar to the main view, to avoid confusion in the user.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: Size.fromHeight(
MediaQuery.of(context).size.height * 0.15), //Adaptive height
child: AppBar(
backgroundColor: Color(0xFF2E8BC0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
flexibleSpace: Container(
child: Image.asset('assets/images/miniloon.png',),
padding: const EdgeInsets.only(
bottom: 20, top: 30, left: 30, right: 30),
)
)
),
body: (…)
}
As you can see, it’s identical to the main view’s build() method.
In the body parameter you set up the actual charts. We put it inside a Container Widget, just to be able to control its size and other properties if need be and then actually set up the charts’ parameters.
body: new Container(
padding: EdgeInsets.all(20),
child: new charts.TimeSeriesChart(
sortedData,
animate: false,
behaviors: [
new charts.PanAndZoomBehavior(),
new charts.SeriesLegend()
],
dateTimeFactory: const charts.LocalDateTimeFactory(),
)
)
As you can see, you need to call charts.TimeSeriesChart(…) to display your chart. The sortedData is a List<charts.Series<dynamic, DateTime>> that is needed for the package to understand what to plot. We used a Time Series Chart, but there are plenty of more types available. You can see them in this official Google's githubio pages link.
To set up the sortedData list, we call our helper function in the initState(), which grabs the values from the database, and sorts them into a list with TemperatureValues(or any other parameter) class and DateTime which is part of the Flutter core, which represents an instant in time as number of seconds since epoch (1970-01-01 UTC). Our helper class for values is simple:
class TemperatureValues {
final DateTime time;
final int temp;
TemperatureValues(this.time, this.temp);
@override
String toString() {
return time.toString() + " / " + temp.toString();
}
For our helper function, it gets all the values and timestamps from the database and puts it into a list. Each entry of the list is a string with the structure ‘{timestamp:
timestamp
, value:
value
},
’.
List<String> strList = [];
if (dataSnapshot.value != null) {
List<dynamic> val = dataSnapshot.value;
//print(dataSnapshot.value);
val.forEach((element) {
strList.add(element.toString());
});
}
return strList;
After doing this, we remove any unnecessary characters, i.e. the special characters and any letters, which leaves us with just the timestamp and its respective value.
List<String> getData(List<String> localData) {
splitString = localData.toString().split(" ");
splitString.removeWhere((element) =>
element.contains("value") || element.contains("timestamp"));
final exp = RegExp(r'[-+]?\d*\.\d+|\d+');
if (splitString != null) {
splitString.forEach((element) {
try {
if (newList != null) {
newList.add(exp.firstMatch(element).group(0));
}
} catch (Exception) {
print("Ex. caught");
}
});
}
return newList;
}
Then what’s left is to put each of these values into the list with the correct format. This means putting the timestamp we get into a DateTime and TemperatureValues (or other parameters) and returning it as a List<Series<dynamic, DateTime>> that the package can understand. This is how we did that:
List<charts.Series<TemperatureValues, DateTime>> setChartList(List<String> data) {
for (int i = 0; i < data.length - 1; i += 2) {
fData.add(new TemperatureValues(
new DateTime.fromMillisecondsSinceEpoch(
double.parse(data[i + 1]).round() * 1000),
double.parse(data[i]).round()
)
);
}
return [new charts.Series<TemperatureValues, DateTime>(
id: 'Temperature',
colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault,
domainFn: (TemperatureValues temp, _) => temp.time,
measureFn: (TemperatureValues temp, _) => temp.temp,
data: fData,
)];
}
The result you get is a great-looking chart with interaction built-in, like tapping the values and zoom:
We built a button on the main view to access a different view for the height control. Unfortunate we didn’t have enough time to implement the communication for the hight control so this button has no real use, but maybe we will implement that later.
//Height Control Button
Container(
padding: EdgeInsets.only(left: MediaQuery.of(context).size.width * 0.18),
child: FlatButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
color: Color(0xFFB1D4E0),
textColor: Color(0xFF0C2D48),
padding: EdgeInsets.all(
MediaQuery.of(context).size.width * 0.033),
onPressed: () { Navigator.push(
context,
MaterialPageRoute(builder: (context) => Height())
);},
child: Text(
"Height Control",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: MediaQuery.of(context).size.width * 0.045
),
),
),
)
We use a FlatButton with some border radius to achieve a more modern look.
On pressing, it takes you to a different view with two buttons to add or take height to the balloon. The buttons are both circular buttons (FlatButton with CircleBorder() as shape) with an Icon.keyboard_arrow_up/down_rounded, which are Icons built in to Flutter.
It also shows a Toast (a little notification on the bottom of the screen) if you reach max or min height.
The code to show this is simple:
//Upwards Button
Container(
padding: EdgeInsets.only(
top: MediaQuery.of(context).size.height * 0.09),
child: FlatButton(
shape: CircleBorder(),
color: Color(0xFFB1D4E0),
textColor: Color(0xFF0C2D48),
padding: EdgeInsets.all(MediaQuery.of(context).size.width * 0.033),
onPressed: () {
setState(() {
if (height < max) {
height++;
} else {
showToast();
}
});
},
child: Icon(Icons.keyboard_arrow_up_rounded,
color: Color(0xFF0C2D48),
size: MediaQuery.of(context).size.width * 0.2
)
),
),
For one of the buttons,
showToast() {
String msg;
if (height == max) {
msg = 'Maximum Height Reached.';
} else {
msg = 'Minimum Height Reached.';
}
Fluttertoast.showToast(
msg: msg,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM
);
}
And the code to show the toast.
Result:
We wanted to be able to force portrait orientation on the home page but allow landscape in the charts. We found that the easiest way to do this is to set the device orientations in the initState() method and reset it in the dispose() method (only on Stateful Widgets). This is how we set it up:
Main View
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
}
Chart View
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitDown,
DeviceOrientation.portraitUp,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight
]);
(…)
The reset was only necessary in the Chart View and it looks the same as the Main View setup, setting it only to DeviceOrientation.portraitUp.
Results:
The main goal of this module is to connect the IoT devices to the internet and the external devices that we can interact with.
In order to achieve this goal, the module consists of several submodules which handle the communication with the other domain (IoT devices, smartphone app, internet, etc.) using a defined API combined with hardware interfaces (WIFI, low energy WIFI, radio, etc.).We wanted to be able to guarantee a stable communication between the other parts of the system (balloons and app) and later takeover certain computing processes and organize them in clusters to relieve the app.
This is what our system architecture looked like:
When looking at the software the main goal is to keep it simple and as short as possible while still guaranteeing all functionalities and following recent coding standards.
Step 3.1: Setup the Raspberry Pi 4The first step was to setup the Raspberry Pi 4 (with the Raspbian for Raspberry Pi Imager). Unfortunately setting up the WIFI did not work so we have to use a ground station via LAN.
When discussing which language to use - Python or Golang - we chose Golang because of its benefits and easy handling when it comes to multithreading and parallelism.
Before starting to code we worked through the following sequences to make sure we would not forget any functionality:
We then started working through the example code and put together the pieces we needed.
The links for the examples you can find here:
- https://godoc.org/github.com/TheThingsNetwork/go-app-sdk#example-package
- https://github.com/plgd-dev/go-coap/blob/master/server.go
We want to use CoAP (constrained Application Protocol using UDP) and LoRaWAN (Long Range Wide Area Network).
We implement one coapgateway.go
file in which we firstly start a CoAP gateway instance in the setup()-function. The config.json
File is read containing the data to connect to the system components. The superloop for fetching and pushing the data is started:
{
"app_access_key": "---",
"db_url": "---",
"cred_file": "---"
}
Config.json
withApp_Access_Key, db_url andcred_file
You find these data in google-services.json.
The superloop for fetching and pushing the data is started:
//Startup starts a coap gateway instance
func Startup(){
jsonFile, err := os.Open("config.json")
if err != nil {
fmt.Println(err)
}
byteValue, _ := ioutil.ReadAll(jsonFile)
var configjson map[string]string
json.Unmarshal([]byte(byteValue), &configjson)
jsonFile.Close()
conf := &firebase.Config {
DatabaseURL: configjson["db_url"],
}
opt := option.WithCredentialsFile(configjson["cred_file"])
app, err := firebase.NewApp(context.Background(), conf, opt)
if err != nil {
_ = fmt.Errorf("error initializing app: %v", err)
return
}
dbClient, err := app.Database(context.Background())
ref := dbClient.NewRef("test")
balloonRef := ref.Child("balloons")
err = balloonRef.Delete(context.Background())
if err != nil {
log.Fatalf("%v", err)
return
}
//Loop for fetching and pushing Data
for {
//Example Balloon URI = "[fe80::24ae:a4ff:fef6:7544%ete0]:5873"
balloonData, err := prepareData("---")
if err != nil {
log.Fatalf("%v", err)
return
}
pushToFirebase(app, balloonData)
}
}
To get the balloon URI you need to write "ifconfig"into the python terminal which opens after the board is flashed.
The function prepareData(balloonURI string) connects to the balloons, fetches the values changes the resolution for the temperature, humidity and pressure and returns a new balloonObject with the receives data.
This is then pushed to the firebase.
func pushToFirebase(app *firebase.App, data *BalloonData) error {
ctx := context.Background()
//Create a database client form App.
client, err := app.Database(ctx)
if err != nil {
log.Fatalln("Error initializing database client:", err)
return err
}
(...)
}
To push to the firebase a database client from the app is created before every value is being pushed.
The structure used in the firebase is the same we used in the gateway, so that we can simply transfer the values. The structure we use:
//Temerature describes a measured temperature value
type Temperature struct {
Time int64 'json:"time"'
Value float64 'json:"value"'
}
// BalloonData contains the relevant data measured by the onboard sensors
type BalloonData struct {
MapX float64 'json:"mapX"'
Mapy float64 'json:"mapY"'
TimeStamp uint64 'json:"Timestamp"'
Humidity float64 'json:"Humidity"'
Temperature *Temperature 'json:"temperature"'
Pressure float64 'json:"pressure"'
}
Step 3.3: Implement LoRA-ConnectionTo implement the LoRA-Connection we implemented the loragateway.go
-File. Again a connection is set up with the data provided in config.json
.
// Startup sets up the cluster
func Startup(){
log := apex.Stdout() // We use a cli logger at Stdout
log.MustParseLevel("debug")
ttnlog.Set(log) // Set the logger as default for TTN
jsonFile, err := os.Open("config.json")
if err != nil {
fmt.Println(err)
}
byteValue, _ := ioutil.ReadAll(jsonFile)
var configjson map[string]string
json.Unmarshal([]byte(byteValue), &configjson)
jsonFile.Close()
// Create a new SDK configuration for the public community network
config := ttnsdk.NewCommunityConfig(sdkClientName)
config.ClientVersion = "2.0.5" // The version of the application
// Create a new SDK client for the application
client = config.NewClient(appID, string(configjson["app_access_key"]))
// Initialize Firebase connection
conf := &firebase.Config{
DatabaseURL: configjson["db_url"],
}
opt := option.WithCredencialsFile(configjson["cred_file"])
app, err := firebase.NewApp(context.Background(), conf, opt)
if err != nil {
_ = fmt.Errorf("error initializing app: %v", err)
return
}
dbClient, err := app.Database(context.Background())
ref := dbClient.NewRef("clusters")
err = ref.Delete(context.Background())
if err != nil {
log.Fatalf("%v", err)
return
}
(...clusters...)
Step 3.4: Implement ClusterThe requirement to be able to sum up balloons to clusters we implemented the fileclusters.json
. It contains the clusters and the balloons belonging to them.
To offer more than just the most recent values a balloon sent, we implemented clusters that are calculating the average values as soon as a balloon sends new data. Every cluster has a name (such as „Hamburg Stadtpark“ for example) and consists of the average values for temperature, humidity, pressure, and the balloons the values were sent from.
{
"clusters": {
"hamburg_stadtpark": {
"balloons": {
"test_device": {},
"ttgo" : {}
}
}
}
}
Example clusters.json
// Initialize clusters lust and mutex
clusters = &gatewaytypes.ClusterLust{}
clusterMutex = syncMutex{}
// Open our jsonFile
jsonFile, err := os.Open("cluster.json")
if err != nil {
fmt.Println(err)
}
defer jsonFile.Close()
byteValue, _ = ioutil.ReadAll(jsonFile)
json.Unmarshal(byteValue, clusters)
// Initialize clusters
pushToFirebase(app)
devices, err := client.ManageDevices()
if err!= nil {
log.WithError(err).Fatal("riot-gateway: could not get device manager")
}
// List the first 10 devices
deviceList, err := devices.List(10, 0)
if err != nil {
log.WithError(err).Fatal("riot-gateway: could not get devices")
}
log.Info("riot-gateway: found devices"
for _, device := range deviceList {
fmt.Printf("-%s\n", device.DevID)
}
wg := sync.WaitGroup{}
for _, cluster := range clusters.Clusters {
for balloonID := range cluster.Balloons {
if checkIfBalloonOnline(balloonID, deviceList) {
go balloonHandler(app, &wg, balloonID) //Spawn balloon handler
wg.Add(1)
} else {
fmt.Printf("Balloon defined is not online\n"
}
}
}
Step 3.5: Calculate the Height of a BalloonTo get some more information out of the measured values we implemented the functionality to calculate the hight using the temperature and barometric air pressure (barometric altimeter) to identify unusual altitudes in the height very quickly.
We use the values to calculate the height altitude for a balloon.
// calculateAltitude calculates the altitude of a balloon
func calculateAltitude(clusterID string, balloonID string) {
balloon := clusters.Clusters[clusterID].Balloons[balloonID]
p := balloon.Pressure[len(balloon.Pressure)-1] //Get latest presure value
t := ballon.Temperature[len(balloon.Temperature)-1] //Get latest temperature
// Calculate the altitude
altitude := ((math.Pow(p0/p.Value, 1/5.257) - 1) * (t.Value + 273.15)) / 0.0065
balloon.Altitude = altitude
}
Step 3.6: Choose between CoAP and LoRAIn the main.go
File we choose between CoAP and LoRA and start the chosen go-File.
func main() {
if os.Getenv("GATEWAY_TYPE") == "LORA" {
loragateway.Startup()
loragateway.CloseClient()
} else if os.Getenv("GATEWAY_TYPE") == "COAP" {
coapgateway.Startup()
} else {
fmt.Println("No gatewaytype supplied!\nusage:\n GATEWAY_TYPE=LORA or\n
GATEWAY_TYPE=COAP\n in envvars")
}
}
As we can see in the above code snippets basically the principles of getting the balloon information differ from loragateway.go
to coapgateway.go
.
When using LoRA the gateway subscribes to certain topics from messages the balloons publish. This is done via MQTT. When using CoAP the needed data is requested.
Last Part: Launch the BalloonWell done!
You have worked throught the entire project. The only thing you have to do now is to flush your board, attach the battery to the board and than everything to your 3D printed part. Fill your balloon with helium, attach a bottle under the 3D part and a fishing line so your mimiLoon can't fly away. Don't forgett to start your gateway (Raspberry Pi).
Have fun.
Comments