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 depthTemperatur_2→ Sensor 2, 1.5 m depthTemperatur_3→ Sensor 3, 2.5 m depthBatterieProzent→ battery percentageLeitwert→ TDS / conductivity valuePH→ 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.
- Open the dashboard in Safari.
- Tap the Share icon.
- Choose Add to Home Screen.
- Give it a name, for example Sensor Dashboard.
- 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:
- Create the history entry.
- Encode it as JSON.
- 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.