pet2print documentation

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.

Scope

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.

Signals it displays

MetricJSON keyUnitTarget bandTurns red
Hot-end temperature temp°C 200 ± 5< 195 or > 205
Auger speed rpmrpm 4 ± 1< 3 or > 5
Filament diameter diametermm 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.

User guide

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:

The controls

Connect
Opens the broker connection and subscribes to the telemetry topic. The button becomes Disconnect while a session is live. The status pill to its left reports the real state: offline, connecting, connected, reconnecting, or the broker's own error text.
Settings
Cluster host, WebSocket port, username, password, topic and path. Values are written to the browser's localStorage, so a phone remembers them between visits. Reset to defaults clears the saved copies and restores the values baked into the file.
Log
Every received message, numbered and timestamped, with its three values. Any reading outside its band is coloured red and marked with an exclamation mark, so a fault that lasted two seconds an hour ago is still findable. The log persists across reloads and holds the most recent 5,000 entries.
Demo data
Synthesises plausible readings in the browser, with no broker and no network. Useful for showing the interface when the machine is not powered, and for checking the page works before blaming the connection.
Target RPM
A closed loop, described in full below.

The target RPM loop

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:

ConditionPublished motor_aksiyonu
Running more than 0.25 rpm above targetYAVASLAT
Running more than 0.25 rpm below targetHIZLANDIR
Within 0.25 rpm of targetnothing 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.

Set stays a manual action. The loop never starts by itself on page load, and it never resumes silently after a reconnect. Anything that drives a motor should require a deliberate press.

Reference

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 edge-device variant

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.

Connection

SettingValueNote
Transportwss://TLS WebSocket, required by HiveMQ Cloud
Browser port8884WebSocket over TLS
Native port8883MQTT over TLS, used by the Python generator
Path/mqttClear it if the handshake fails
Topicpet2print/telemetryReadings 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.

Demo data

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
Two kinds of demo. This script exercises the real network path: broker, TLS, subscription, parsing. The in-page Demo data button bypasses all of that and generates readings locally. When something is broken, the difference between the two tells you which half is at fault.

Build log

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.

17 May 2026

A local broker and a native app replaced

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.

17 June 2026

Docker to HiveMQ Cloud kept

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.

20 to 21 June 2026

Expo dropped for a single HTML file kept

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.

21 June 2026

Repository hygiene kept

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.

22 June 2026

Ranges, logging and closed-loop control kept

With the machine running, the interface met reality and changed quickly.

Acceptable ranges were revised twice

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.

A renaming experiment was reverted

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.

The message log was added

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.

Target RPM became a control loop

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.

22 June 2026

ESP32 network access kept

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.

Retrospective

What the detours were worth