The telemetry dashboard for a desktop appliance that recycles post-consumer PET bottles into 1.75 mm 3D-printing filament. This page covers how to run the dashboard and how it was built.
Where this piece sits in the wider machine
PET2Print is a capstone project built by a team. The full appliance shreds bottles, feeds the flakes through a heated auger, pulls the melt into filament, and measures the result optically as it cools.
This repository is one component of that system: the real-time telemetry layer. It subscribes to whatever the machine publishes over MQTT and renders it live in a browser. The OpenCV optical measurement and the ESP32 firmware are separate parts of the project, maintained elsewhere. The dashboard never talks to the sensors directly, so it stays useful regardless of how the measurement side changes: it only cares about the broker and the message shape.
Everything ships as a single index.html. The MQTT and charting libraries
are inlined into the file, so there is no build step, no bundler, no backend and no
install. Opening the file is running it.
| Metric | JSON key | Unit | Target band | Turns red |
|---|---|---|---|---|
| Hot-end temperature | temp | °C | 200 ± 5 | < 195 or > 205 |
| Auger speed | rpm | rpm | 4 ± 1 | < 3 or > 5 |
| Filament diameter | diameter | mm | 1.75 ± 0.05 | < 1.70 or > 1.80 |
Each card carries a chart of the last 60 readings with dashed lines at the lower limit, the target and the upper limit, so a drift shows up as a slope long before it trips a threshold.
Running the dashboard
The hosted copy at pet2print.netlify.app is ready to use and needs nothing installed. It works on a phone, which is the point: the machine operator watches it from a handset next to the extruder rather than from a laptop.
To run it locally instead, do any one of these:
index.html straight from disk in a browser,python3 -m http.server 8000.localStorage, so a phone remembers them
between visits. Reset to defaults clears the saved copies and
restores the values baked into the file.
The auger runs between 0 and 15 rpm. The slider sets a target in steps of 0.5, and Set starts regulating. Once a second the dashboard compares the most recent reported rpm against the target and publishes a correction:
| Condition | Published motor_aksiyonu |
|---|---|
| Running more than 0.25 rpm above target | YAVASLAT |
| Running more than 0.25 rpm below target | HIZLANDIR |
| Within 0.25 rpm of target | nothing sent |
That 0.25 rpm deadband is what stops the loop oscillating: without it, a reading that sits a hundredth above target produces a slow-down command, the next reading sits a hundredth below, and the motor is told to speed up again forever.
The button becomes Stop while regulating, and the hint line under the slider shows the current reading, the target and what was just sent. Regulation stops on its own if the broker connection drops. Targets outside the 3 to 5 rpm band are accepted only after a confirmation, since that is the range the extruder was characterised at.
Message format
The dashboard expects JSON on the telemetry topic:
{ "temp": 200, "rpm": 3.8, "diameter": 1.75 }
All three keys are optional. A message carrying none of them is ignored, which is what keeps the dashboard's own outgoing commands from being counted as readings. Values may arrive as numbers or as numeric strings.
The measurement side reports diameter under the key kalinlik and stamps
each message with a kaynak field naming its source. The dashboard maps
kalinlik onto diameter only when kaynak is
Edge_Phone:
{ "kalinlik": 1.74, "motor_aksiyonu": "", "kaynak": "Edge_Phone" }
The source check is load-bearing. Commands and readings share one topic, so without it the dashboard would read its own published commands back as measurements.
| Setting | Value | Note |
|---|---|---|
| Transport | wss:// | TLS WebSocket, required by HiveMQ Cloud |
| Browser port | 8884 | WebSocket over TLS |
| Native port | 8883 | MQTT over TLS, used by the Python generator |
| Path | /mqtt | Clear it if the handshake fails |
| Topic | pet2print/telemetry | Readings and commands both |
The URL is assembled as wss://host:port/path. Browsers refuse plain
ws:// from a page served over HTTPS, which is why the hosted dashboard is
TLS end to end.
Driving the dashboard without the machine
demo_data_generation/fake_data.py publishes randomised readings to the
telemetry topic every two seconds, so the full path through a real broker can be
exercised without the extruder running.
It declares paho-mqtt inline using PEP 723 script metadata, so
uv resolves and installs the dependency on
first run. There is no virtualenv to create and no pip install step:
uv run demo_data_generation/fake_data.py
On Windows there is a launcher that does the same thing:
demo_data_generation\start_data_generation.ps1
How the dashboard reached its current shape
The telemetry layer went through three transports, two client frameworks and several revisions of the acceptable ranges before settling. What follows is the sequence, kept honest about the parts that were abandoned.
The first setup was a Mosquitto broker on a laptop with a React Native client talking to it, and a short Python script publishing fake readings so there was something to look at.
Problem
The generator failed with No module named paho under uv.
Fix
Declare the dependency in the file itself with PEP 723 inline metadata rather than
managing an environment around it. This is still how the generator works today,
and it is the reason the script is runnable straight from a clone.
Problem
The React Native client used mqtt://, which is MQTT over raw TCP.
React Native has no Node net module, so that transport cannot work
there at all.
Fix
Move to MQTT over WebSockets. The same change also brought in cleanup on unmount,
a guarded JSON.parse and a visible connection state, all of which
survived into the final dashboard even though the framework did not.
Problem
Mosquitto would not open a WebSocket listener. The Windows build in use had been
compiled without libwebsockets, so the config was valid but the
feature was simply absent: the verbose log showed a TCP listener and no WebSocket
one.
Fix
Stop fighting the broker build and move to one that ships WebSockets by default.
EMQX turned out to have no Windows installer either, so it ran in Docker.
A broker in Docker on somebody's laptop is only reachable while that laptop is on the same network and awake. Since the point was to watch the machine from a phone, the broker moved to HiveMQ Cloud on its free tier.
That removed the local dependency entirely and forced TLS, which is what fixed phone access for good: a page served over HTTPS may not open an unencrypted WebSocket, so a cloud broker with TLS was the only combination that worked from a handset without local setup.
Problem
The generator emitted a deprecation warning for paho's version 1 callback API.
Fix
Move to CallbackAPIVersion.VERSION2, which is why
on_connect takes the wider signature with
reason_code and properties.
The requirement was a mobile-friendly page showing the three metrics, the connection state and a chart of each value over time. Delivering that as an Expo app meant a toolchain, a build and an install on every phone that wanted to look at it. A single HTML file meant a URL.
The native app was abandoned here. Everything after this point is the web dashboard.
To survive a weak connection in a workshop, the MQTT and charting libraries were
inlined into the file rather than pulled from a CDN. That made the page
self-contained, and it is why index.html is around 600 KB.
Problem
After inlining, a few hundred lines of minified library code appeared as visible
text on the page, starting mid-expression.
Fix
The inlining step had used JavaScript's String.replace(), which
treats $& in the replacement as a back-reference to the matched
text. The minified bundles contain $& as ordinary variable code,
so those positions were substituted with the matched
<script> tag, closing the block early and spilling the
remainder into the document as text. Rebuilding with plain concatenation left the
bundles byte for byte intact.
Problem
The baked-in connection defaults did not apply on the phone: pressing Connect kept
demanding the settings be filled in.
Fix
The loader treated an empty saved value as a real one, so a blank
localStorage entry shadowed the default instead of falling back to
it. The check now accepts a saved value only when it is both present and
non-empty.
The settings panel dates from this phase too: defaults ship in the file, and any change a user makes is saved to their browser alone.
The project was published with a README, a NOTICE listing every third-party component, and GPL-3.0-or-later as the licence. The "or later" clause was a deliberate change from the original draft, which had ruled out future versions of the licence.
NOTICE matters more than it looks here. The libraries inlined into
index.html are MIT licensed, and MIT requires their copyright and
permission notices to travel with the code. Inlining redistributes them, so those
notices have to be reproduced.
With the machine running, the interface met reality and changed quickly.
The original figures came from datasheet expectations: 255 to 260 °C to avoid clogging, with rpm treated as uncritical. Measurement on the actual extruder moved temperature down to a 195 to 205 band, and rpm turned out to matter a great deal, landing at 3 to 5. Both were then restated as a target plus a tolerance, 200 ± 5 °C and 4 ± 1 rpm, because that is how the limits are reasoned about in practice and it makes the chart lines obvious. Diameter never moved from 1.75 ± 0.05 mm, since that tolerance is what makes the filament usable in a printer at all.
The payload keys were briefly changed to Sicaklik and
Devir_RPM to match the firmware's local naming. That was rolled back,
and the dashboard settled on temp, rpm and
diameter as its contract, with the edge-device variant handled by the
kalinlik and kaynak mapping instead. Keeping one shape
and translating at the boundary proved easier to reason about than renaming
throughout.
Live gauges answer what is happening now, which is no help when the question is
what happened forty seconds ago. The log records every message with a number and a
timestamp, persists to localStorage so a reload does not erase the
session, marks out-of-range values, and can be cleared on its own or together with
the saved connection settings.
The first design published a TargetRPM value and let the firmware work
out how to reach it. That was replaced with the loop the dashboard uses now,
because the firmware's interface was a pair of nudge commands rather than a
setpoint: read the current rpm, compare it against the target, and send
HIZLANDIR or YAVASLAT once a second until it is close
enough. The interval started at 500 ms and was doubled to 1000 ms to give the
motor time to respond before being corrected again.
Commands are published as JSON with the fields in a fixed order,
kalinlik then motor_aksiyonu then kaynak,
because the receiving end expected that shape. A stop button was added in the same
pass: any loop that drives hardware needs a way to be switched off that does not
involve closing the tab.
The extruder controller could not join an iPhone personal hotspot. The board is a 2.4 GHz-only part, which made a band mismatch the obvious suspect, but that also meant the ESP32 could not have been reaching for 5 GHz in the first place: the radio has no such mode.
The useful diagnostic was to scan before connecting. In MicroPython,
wlan.scan() lists what the radio can actually see, which separates a
hotspot that is not being broadcast from one that is visible but rejecting the
association. Hotspot names containing apostrophes, the default on iOS, are a
known source of trouble for the ESP32 network stack, and a plain name avoids it.
What the detours were worth
kaynak source check and the "ignore messages with no metrics" guard
both exist so the dashboard does not read its own output back as measurement.