Learning how to display live LoRaWAN sensor data from The Things Network on an iPad dashboard

In this tutorial we build a simple live dashboard for environmental sensor data.
The data comes from a LoRaWAN sensor buoy, is received by The Things Network,
forwarded to a small web API via webhook, and displayed on a website.

The final result is a fullscreen iPad dashboard that can be mounted near a lake and used as a public information display.

See here: parkli.gks-wuestenrot.de

Download the example files

The complete example project, including the HTML dashboard, the legacy iPad kiosk version,
the PHP webhook endpoints and sample JSON files, is available on GitHub:


github.com/johanneskalt/parkli-lorawan-dashboard

What we wanted to build

We had a sensor buoy in a lake. The buoy measures:

  • water temperature in three depth layers
  • pH value
  • TDS / conductivity value
  • battery status and LoRaWAN signal quality

The main focus of the display is the water temperature.
pH and TDS are shown as smaller additional values.

The three temperature sensors are displayed like this:

  • Sensor 1: 0.5 m depth, green
  • Sensor 2: 1.5 m depth, yellow
  • Sensor 3: 2.5 m depth, blue

The basic architecture

The final data flow looks like this:

Sensor buoy
  ↓ LoRaWAN
The Things Network
  ↓ Webhook
Small PHP endpoint on our webspace
  ↓ JSON API
HTML dashboard
  ↓
iPad in fullscreen kiosk mode

We deliberately did not connect the browser directly to The Things Network.
The reason is simple: API keys and secrets should not be stored inside frontend code.
Instead, The Things Network sends every uplink to our own backend endpoint.

Step 1: Test the webhook with webhook.site

Before building our own endpoint, we tested whether The Things Network could send
the uplink data to an external URL.

For that we used:

https://webhook.site

webhook.site automatically creates a unique test URL.
This URL was entered as a custom webhook in The Things Network.

In The Things Network we used these settings:

  • Webhook format: JSON
  • Downlink API key: empty
  • Basic authentication: disabled
  • Event types: Uplink message only

After the next sensor uplink, a request appeared on webhook.site.
This confirmed that the LoRaWAN data was successfully forwarded.

Step 2: Inspect the TTN payload

The incoming JSON contained the decoded sensor values in this section:

uplink_message.decoded_payload

In our case the relevant values looked like this:

{
  "Air_Temperature": 20.75,
  "BatterieProzent": 90,
  "Humidity": 49.93,
  "Leitwert": 0,
  "PH": 1175,
  "Pressure": 951,
  "Temperatur_1": 18.75,
  "Temperatur_2": 19.62,
  "Temperatur_3": 19.31,
  "Temperatur_4": -127,
  "Temperatur_5": -127
}

We mapped the values like this:

  • Temperatur_1 → Sensor 1, 0.5 m depth
  • Temperatur_2 → Sensor 2, 1.5 m depth
  • Temperatur_3 → Sensor 3, 2.5 m depth
  • BatterieProzent → battery percentage
  • Leitwert → TDS / conductivity value
  • PH → pH raw value

The values Temperatur_4 and Temperatur_5 returned -127.
We treated this as “not connected” or “invalid sensor value”.

Step 3: Create a small PHP webhook endpoint

Our website runs on normal webspace, so we used PHP instead of a larger backend.
In the webroot of the subdomain we created this folder structure:

api/
  data/
  ttn/
    uplink/
      index.php
  parkli/
    latest/
      index.php

The endpoint for The Things Network is:

https://YOUR-SUBDOMAIN.example.org/api/ttn/uplink/

This endpoint receives the TTN webhook via HTTP POST, extracts the values,
stores the latest measurement as JSON and appends a history entry.

The data is stored in:

api/data/latest.json
api/data/history.jsonl

The second PHP endpoint is used by the dashboard:

https://YOUR-SUBDOMAIN.example.org/api/parkli/latest/

It returns the latest values and the temperature history as JSON.

Step 4: Verify that the API works

Before connecting The Things Network to the real endpoint, we tested whether PHP
was running correctly on the subdomain.

We created a simple file:

php-test.php

with this content:

<?php
header('Content-Type: text/plain; charset=utf-8');
echo "PHP OK";

Opening this file in the browser confirmed that PHP was executed correctly.

Then we opened the dashboard API:

https://YOUR-SUBDOMAIN.example.org/api/parkli/latest/

Before the first TTN uplink, the API returned empty values:

{
  "deviceId": null,
  "temperature": {
    "s1": null,
    "s2": null,
    "s3": null
  },
  "ph": null,
  "tds": null,
  "battery": null,
  "rssi": null,
  "snr": null,
  "history": []
}

This was good: it meant the API endpoint was reachable and returned valid JSON.

Step 5: Point the TTN webhook to the real endpoint

After the local API worked, we changed the custom webhook in The Things Network.

The final target was:

https://YOUR-SUBDOMAIN.example.org/api/ttn/uplink/

The webhook settings were:

  • Base URL: https://YOUR-SUBDOMAIN.example.org/api/ttn/uplink/
  • Downlink API key: empty
  • Basic authentication: disabled
  • Event types: Uplink message only

After the next sensor uplink, the API returned real live data:

{
  "deviceId": "YOUR-DEVICE-ID",
  "temperature": {
    "s1": 18.87,
    "s2": 20.06,
    "s3": 19.37
  },
  "ph": 11.78,
  "tds": 0,
  "battery": 90,
  "rssi": -48,
  "snr": 11.2,
  "history": [
    {
      "timestamp": "2026-05-13T09:20:54.503924991Z",
      "date": "2026-05-13",
      "s1": 18.87,
      "s2": 20.06,
      "s3": 19.37,
      "ph": 11.78,
      "tds": 0
    }
  ]
}

Step 6: Build the dashboard as a single HTML file

The dashboard itself was built as a single index.html file.
It does not need a build system, React setup or external chart library.

The HTML page fetches the live data from:

/api/parkli/latest/

The page displays:

  • the current temperature at 0.5 m depth
  • the current temperature at 1.5 m depth
  • the current temperature at 2.5 m depth
  • a line chart for the temperature history
  • pH value
  • TDS / conductivity value
  • battery and LoRaWAN signal quality

The chart is drawn directly on an HTML <canvas>.
This keeps the page lightweight and avoids dependencies.

Step 7: Add project logos

We also added project and partner logos to the page.
The image files were placed in the same folder as index.html:

index.html
YOUR-COMMUNITY-LOGO.png
YOUR-SCHOOL-LOGO.png
YOUR-PROJECT-LOGO.jpg

The school logo is used in the header.
The project and community logos are shown in the footer area.

One small detail was important: the school logo has a transparent background.
If the CSS sets a black background on the image container, the logo appears on black.
To use the white card background instead, the CSS background must be white or transparent.

.school-mark {
  background: white;
  padding: 8px;
}

Step 8: Optimize the layout for iPad portrait mode

The display target was Safari on an iPad in portrait orientation.
The first version was slightly too tall and required scrolling.

We reduced:

  • body padding
  • vertical gaps
  • card heights
  • chart height
  • font sizes in smaller sections

The goal was that all important information fits on one screen:

  • header
  • three temperature cards
  • temperature chart
  • pH and TDS values
  • project logos
  • battery and signal information

Step 9: Add the website to the iPad Home Screen

To remove Safari’s address bar and tabs, the website was added to the iPad Home Screen.

  1. Open the dashboard in Safari.
  2. Tap the Share icon.
  3. Choose Add to Home Screen.
  4. Give it a name, for example Sensor Dashboard.
  5. Tap Add.

The dashboard can now be started from the Home Screen like an app.
This gives a much cleaner fullscreen display than opening the page directly in Safari.

Step 10: Lock the iPad into kiosk mode

For public display use, we enabled Guided Access on the iPad:

Settings → Accessibility → Guided Access

Then we opened the dashboard from the Home Screen and started Guided Access:

  • On an iPad without a Home button: press the top button three times.
  • On an iPad with a Home button: press the Home button three times.

Useful Guided Access options for a public display:

  • Touch: off, if visitors should not interact with the page
  • Keyboard: off
  • Volume buttons: off
  • Time limit: off

We also set Auto-Lock to Never:

Settings → Display & Brightness → Auto-Lock → Never

Update: Sensor mapping, calibration and legacy iPad support

During further testing we made several important changes to the project.
The basic data flow stayed the same, but we improved the way the sensor values are processed
before they are shown on the public dashboard.

Correct temperature sensor mapping

We found out that the physical order of the temperature sensors on the buoy board did not match
the order in which we wanted to display them publicly.

The public dashboard should show the water layers from the surface downwards:

  • Sensor 1: 0.5 m depth
  • Sensor 2: 1.5 m depth
  • Sensor 3: 2.5 m depth

The TTN payload had to be mapped like this:

decoded_payload.Temperatur_2 → Sensor 1 / 0.5 m
decoded_payload.Temperatur_3 → Sensor 2 / 1.5 m
decoded_payload.Temperatur_1 → Sensor 3 / 2.5 m

We implemented this mapping in the PHP webhook receiver, not in the frontend.
This keeps the dashboard simple. The frontend still only reads:

temperature.s1
temperature.s2
temperature.s3

The advantage is that the dashboard labels stay clean and logical for visitors:
Sensor 1, Sensor 2 and Sensor 3 from top to bottom.

pH and TDS calculation

We also received the correct conversion formulas from the developer of the ParKli buoy board.
The raw pH and conductivity values from the TTN payload are now converted in the webhook receiver
before they are stored.

The calculation is done in:

api/ttn/uplink/index.php

The pH value is calculated from the raw PH value and the surface temperature.
The TDS value is calculated from Leitwert and the surface temperature.

In this setup, the surface temperature is the temperature at 0.5 m depth, which comes from:

decoded_payload.Temperatur_2

The dashboard itself does not calculate pH or TDS. It only displays the processed values
returned by the JSON API.

Moving average for pH and TDS

pH and TDS measurements can fluctuate quite a bit. To make the public display easier to read,
we added a moving average for these two values.

The moving average is calculated in:

api/parkli/latest/index.php

The current setup uses the latest 6 valid measurements.

The API still keeps the latest calculated values for debugging, but the dashboard reads the
smoothed values from ph and tds.

The API response now also contains helper fields like:

phLatest
tdsLatest
phAverage
tdsAverage
averageWindow

This makes it easier to compare the latest raw calculated value with the value that is actually
shown on the dashboard.

History recording fix

During one update we accidentally broke the history recording.
The problem was the order in the PHP code: the script tried to write the history line before the
history entry was created.

The correct order is:

  1. Create the history entry.
  2. Encode it as JSON.
  3. Append it to history.jsonl.

The history file is stored here:

api/data/history.jsonl

Each TTN uplink appends one JSON line to this file.

Webhook debugging

For debugging we temporarily added a simple log file:

api/data/debug.log

A successful TTN webhook call should create log entries like this:

Webhook called. Method: POST
Raw body length: ...
latest.json written. Device: ...
history.jsonl written

Manual browser tests create GET requests. These are expected to be rejected,
because the TTN webhook endpoint only accepts POST requests.

Legacy iPad support

We also tested the dashboard on an older iPad. The normal Safari view worked, but the Home Screen
web app showed a broken older layout. This was most likely caused by aggressive caching or an older
WebView implementation in iOS.

The solution was to create a separate legacy file:

kiosk_legacy.html

This version avoids modern JavaScript and CSS features. It does not rely on fetch,
const, let, arrow functions or CSS Grid.

On older iPads, adding this dedicated file to the Home Screen worked better than adding the root
index.html URL.

GitHub repository

The project files are now documented and available on GitHub.
The repository contains the dashboard files, the legacy iPad version, the PHP webhook endpoints,
sample JSON files and documentation.


github.com/johanneskalt/parkli-lorawan-dashboard

Runtime data such as latest.json, history.jsonl, debug.log
and reset.txt should not be committed to GitHub.

Things we learned

  • webhook.site is very useful for testing TTN webhooks before writing backend code.
  • The dashboard should not connect directly to TTN, because credentials would be exposed.
  • A small PHP endpoint is enough for a simple live dashboard.
  • Flat JSON files can be sufficient for a first prototype.
  • For long-term use, a database would be better for history and aggregation.
  • iPad fullscreen mode works best when the website is added to the Home Screen.
  • Guided Access is a simple built-in kiosk mode for iPads.

Possible improvements

  • Store the measurements in a real time-series database.
  • Aggregate the chart data hourly or daily.
  • Add calibration logic for pH and TDS values.
  • Add error logging for failed webhook requests.
  • Add a small admin page for checking the latest raw TTN payload.
  • Add automatic reload if the iPad loses connection.

Result

We now have a working live environmental dashboard:

  • The sensor buoy sends data via LoRaWAN.
  • The Things Network forwards the uplinks via webhook.
  • A small PHP API stores and serves the latest values.
  • A custom HTML dashboard displays the data.
  • An iPad shows the dashboard in kiosk mode at the lake.

This is a lightweight and understandable setup for schools, communities and small
environmental monitoring projects.


Tutorial done with ChatGPT, tested by real humans.

Learning to Sync Two Directories With rsync On Command Line

On macOs-machines (and other unix-like systems most likely too) you can use rsync command to sync two directories in many different ways.

This post is just a quick reminder two myself for a specific use. See links below for further info!

Imagine you’ve copied some file structure to a new directory, have by accident lost some of the files during that process and further more started to working on the files in the new file structure.

You might have some issues if you want to copy the missing files from the old file structure to the new one without searching too long AND without erasing your new work in the new structure if you’d just repeated the copy process.

I used rsync as follows:

rsync -rP /my/old/directory/ /my/new/directory

-r Option: Recursive, traverse into subdirectories
-P Option: Equivalent to –partial –progress: Show progress during transfer
Note that you need to put a slash after your old directory. If not rsync will simply copy your old directory in the new one instead of searching through your old subdirectories for changes.

Ressources I’ve been using to learn this

https://ss64.com/osx/rsync.html

https://www.digitalocean.com/community/tutorials/how-to-use-rsync-to-sync-local-and-remote-directories-on-a-vps

http://lucasb.eyer.be/snips/rsync-skipping-directory.html

Learning to Clone A Raspberry Pi’s SD Card (on command line of course)

I wanted to be able to clone a Raspberry Pi’s SD card to having some state that I could go back to if I had messed up to much in this Raspberry Pi’s OS.

As I’m into learning as much command line stuff as possible this is an command line only approach using a Mac (MacOS 10.14.4). Continue reading Learning to Clone A Raspberry Pi’s SD Card (on command line of course)

Learning how to manually backup a self hosted WordPress installation

Backup Database (no plugins required)

  1. Lock into WP backend with admin rights
  2. Chose: Tools/Backup
  3. Tick all boxes to backup all tables
  4. Download and safe file

Drink coffee…

Backup Files (via sftp)

  1. Open sftp client (cyberduck e.g.) locally and log in to webspace
  2. Download and safe all files

Drink more coffee…

 

 

Learning to set up a Development Environment for web Application Projects on OSX

Install Local Server Environment :

To keep it simple, I use MAMP, a one-click-solution to set up my personal developing environment.

I  download the current version from https://www.mamp.info/en/ including the php-version, if other needed then included in the MAMP app. For creating one single development environment the free version is great. For multiple projects the PRO version is reasonable.

I run the installer of MAMP.pkg

Systemwide PHP Version

To make sure I also operate on the same php version like MAMP when running commands from the terminal I update OS X’x PHP version to MAMP’s Version like this:

nano ~/.profile

add to .profile:

export PATH="/Applications/MAMP/bin/php/phpVERSION-#-HERE/bin:$PATH"
export PATH="/Applications/MAMP/bin/php/php5.6.25/bin:$PATH"

Run source command to make this changed active:

source ~/.profile

Check if it’s working with:

php -v

Enable MySQL Commands to run From Terminal

To enable MySQL commands to be run without entering the whole path each time when using on the command line I add as above for the PHP-Version to .profile:

nano ~/.profile

I add to .profile:

export PATH="/Applications/MAMP/Library/bin:$PATH"

I ru the source command to make this changed active:

source ~/.profile

And I check if it’s really working by typing in a MySQL command such as:

MySQL --version

Subversion to Check out Code From Repositories

Subversion is already a part of OS X. Maybe not the newest version, but usable.
So no further steps to be taken.

Check out the Code:

To check out the code I will be working with I use svn checkout command in the directory to which I want the code to be downloaded to:

svn co https://path-to-the-repository/trunk trunk

svn: runs the subversion application
co: runs a checkout
https://path-to-the-repository/trunk: is the remote path where subversion will download the code from
trunk: tells subversion to download the code to a to be created folder called “trunk”

Install Composer Dependencies Locally

Composer is a tool for dependency management in PHP. It allows you to declare the libraries your project depends on and it will manage (install/update) them for you.

https://getcomposer.org/doc/00-intro.md

The above link tells in detail how to install.

Setup PhpStorm

PhpStorm is an integrated development environment that helps with a lot of little details. It costs quite some, but there is a 30 day free trial that showed my the worth of this purchase.

The link below hold all infos for setting up PhpStorm:

https://confluence.jetbrains.com/display/PhpStorm/Installing+and+Configuring+MAMP+with+PhpStorm+IDE

Setup Xdebug

Xdebug is a PHP extension which provides debugging and profiling capabilities.

https://en.wikipedia.org/wiki/Xdebug

https://confluence.jetbrains.com/display/PhpStorm/Zero-configuration+Web+Application+Debugging+with+Xdebug+and+PhpStorm

and with MAMP:

http://stackoverflow.com/questions/11618178/settings-up-xdebug-on-mamp-pro

Ressources I’ve Been Using to Learn This:

https://www.mamp.info/en/

https://www.tutorialspoint.com/svn/index.htm

https://getcomposer.org/doc/00-intro.md

https://confluence.jetbrains.com/display/PhpStorm/Installing+and+Configuring+MAMP+with+PhpStorm+IDE

https://en.wikipedia.org/wiki/Xdebug

https://confluence.jetbrains.com/display/PhpStorm/Zero-configuration+Web+Application+Debugging+with+Xdebug+and+PhpStorm

http://stackoverflow.com/questions/11618178/settings-up-xdebug-on-mamp-pro

Learning how to Back up a Raspberry Pi using rsync

As I started to develop on a Raspberry Pi under Raspbian I needed to find a way to back up the system I’m working on.

I will try to set up this using the linux command rsync. Continue reading Learning how to Back up a Raspberry Pi using rsync

Learning to Setup Postfix to Receive Error Messages From the System of a Raspberry Pi

As I wanted to know more about what the system of my Raspberry Pi was trying to tell me I had to set up a Mail Transfer Agent (MTA). I was using a MTA called postfix in its local only mode. Continue reading Learning to Setup Postfix to Receive Error Messages From the System of a Raspberry Pi

Learning why a Shell Script is not run in boot sequence – Troubleshooting in Raspberry Pi Development

What I want to achive

(but did not so far…)

I want a Raspberry Pi to automatically run a slideshow after the boot sequence. Continue reading Learning why a Shell Script is not run in boot sequence – Troubleshooting in Raspberry Pi Development

Learning to Disable Text Terminals From Blanking in Raspbian (Linux)

EDIT:

By now, I cannot confirm that this is really working… Sorry.

When developing on a Raspberry Pi I wanted to get rid of screen blanking when not touching the terminal for some time. To do so I had to change some settings in a config file as follows. Continue reading Learning to Disable Text Terminals From Blanking in Raspbian (Linux)