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 Download Flickr Photos Using Python and FlickrAPI

This was a quite tricky thing for me to get it running but here it is – how I download photos of a set (album) on Flickr (both public and private) with a python script to a Raspberry Pi. Continue reading Learning to Download Flickr Photos Using Python and FlickrAPI

Still in editing – How to replace images of a wordpress blog in bulk on OS X and Linux systems

I need to replace all non-square pictures of a WordPress WooCommerce shop by square versions for design reasons.

To do so I follow these asghhgfdsasdfghj steps: Continue reading Still in editing – How to replace images of a wordpress blog in bulk on OS X and Linux systems

Still in editing – Learning To Use WordPress Child Themes And Their css For Styling

Problem pending – still working on it…

A while ago I created a child theme for a wordpress theme we kinda liked to run a woocommerce shop.

I did a lot of minor styling of this theme to match it to our needs adding some classes or overwriting some of the parent theme’s classes in my child theme’s style.css.

This worked out fine until I came across a class that got some arguments from a style.css which was not the parent style theme’s but somewhere deeper in that parent theme from an include of the woocommerce plugin.

Adding a class in my browsers developer mode showed me the exact result I was aiming for. But as my child theme’s style.css appeared not to be the last one in the cascade the operation of adding theses classes there failed.

This was one of this things I really had now clue where to start searching… Continue reading Still in editing – Learning To Use WordPress Child Themes And Their css For Styling