For this project, I am developing a digital badge/nametag using the LILYGO® T5-2.13 inch
E-Paper display. The badge will showcase personal details, such as a name, contact
information, and a QR code,
all of which can be updated wirelessly through a custom Flutter mobile app. Communication between the app and the
badge will be handled via Bluetooth Low Energy (BLE).
The badge is designed to be portable, powered by a rechargeable battery, and housed
in a custom 3D-printed case for comfortable wear.
Project Plan
1. Technology Stack
Hardware:
LILYGO® T5-2.13 inch E-Paper (ESP32 based)
LiPo Battery (appropriate size and capacity)
3D Printer & Filament (e.g., PLA, PETG)
Firmware:
ESP32 Development Framework: Arduino Core for ESP32 or ESP-IDF
Libraries: E-Paper Driver (e.g., GxEPD2), BLE (e.g.,
NimBLE-Arduino), Graphics (e.g., Adafruit GFX), QR
Code
Generation, JSON Parsing (e.g., ArduinoJson)
Mobile App:
Flutter SDK
Dart Programming Language
Flutter BLE Package (e.g., flutter_blue_plus)
State Management (e.g., Provider, Riverpod)
QR Code Package (e.g., qr_flutter)
Design Tools:
3D Modeling Software (e.g., Fusion 360, Tinkercad)
Slicer Software (e.g., Cura, PrusaSlicer)
2. Risks & Mitigation
Risk: BLE connection instability. Mitigation: Use robust BLE libraries, implement retry logic in app, thorough
testing.
Risk: E-Paper display library
compatibility/issues.
Mitigation: Research libraries (GxEPD2), start with examples, isolate
testing.
Risk: Battery life insufficient. Mitigation: Choose appropriate battery, implement deep sleep, optimize refresh,
measure consumption early.
Risk: 3D printed case fitment issues. Mitigation: Accurate measurements, iterative prototyping, allow tolerances.
Risk: Data transfer limitations/speed over BLE.
Mitigation: Efficient protocol, potential compression, manage
expectations,
consider data splitting.
Risk: Flutter BLE package quirks across
platforms.
Mitigation: Test on iOS/Android, consult package docs, choose
well-maintained
package.
3. Diagrams
System Architecture Diagram
Diagram shows the Flutter App on a mobile device communicating via BLE to the badge. The
badge contains an ESP32 running firmware, managing a BLE Server and driving the E-Paper
display. A battery powers the components, all housed in a 3D printed case.
Diagram illustrates the sequence: App scans, Badge advertises, App connects, discovers
services, writes data (name, contact, QR data) to a characteristic. Badge firmware
receives
data, parses it, updates the E-Paper display, and optionally notifies the app.
sequenceDiagram
participant App as Flutter App
participant Badge as ESP32 Badge Firmware
App->>Badge: Scan for BLE Devices
Note right of Badge: Badge is Advertising
Badge-->>App: Advertising Packet (with Service UUID)
App->>Badge: Connect Request
Badge-->>App: Connection Established
App->>Badge: Discover Services & Characteristics
Badge-->>App: Service/Characteristic Details
App->>Badge: Write Characteristic (Data: Name, Contact, QR Data)
Note right of Badge: BLE Server receives data
Badge->>Badge: Parse Received Data
Badge->>Badge: Generate Display Buffer
Note right of Badge: Trigger E-Paper Update
Badge->>Badge: Update E-Paper Display
opt Send Status Update
Badge-->>App: Notify/Indicate Status (e.g., Success/Failure)
end
Note left of App: User sees updated badge / App shows status
4. Conceptual Data Structure
Preformatted (JSON Example)
Data sent over BLE could be structured as a JSON string like this:
Where qr contains the data to be encoded into the QR code displayed on the
badge.
Future Enhancements
Multiple display templates/layouts selectable from the app.
Over-the-Air (OTA) firmware updates.
Store multiple profiles on the badge (button switchable).
Image display capability (simple logos/icons).
About me
I'm a tech enthusiast and a Cybernetics and Robotics
Student, currently working in
the Computational Robotics Laboratory at
the Faculty of Electrical Engineering,
CTU in Prague. My work focuses on hexapod robots featuring ROS. I'm also deeply
interested in artificial
intelligence, particularly LLMs, their
architectures, and the latest advancements in this ever-evolving field.
Beyond Engineering
Outside the lab, I enjoy swimming, running, and tackling bike trails and
downhill courses. These activities keep me active and fuel my love for exploration and
adventure.
My Projects
This page is where I’ll showcase my journey through the course "Jak Vyrobit Téměř
Cokoliv", CTU’s version of MIT’s legendary "How to Make (Almost)
Anything"
"The only way to do great work is to love what you do."    - Steve
Jobs
20/02/2025
The Core of Web & Code
Week 1
Subject Information
This subject is inspired by the course How
to Make (Almost) Anything,
led by Prof. Neil Gershenfeld at MIT. It focuses on digital fabrication techniques, teaching
students how to turn ideas into physical prototypes using skills like 3D design,
laser cutting, 3D printing, electronics,
programming, and tools such as CNC machines and
microcontrollers. Each week, students must complete and document tasks on personal
web pages, which may contribute to the final project.
Weekly Update
The first week was all about setting up my website
and figuring out how to document everything for
the course.
I worked on getting the basic structure of my personal page up on GitLab, making sure it’s ready for
weekly updates.
I also started thinking about my final project and how to bring together 3D design,
electronics, and programming.
It was a bit overwhelming, but it felt good to make progress. I’m excited to see how everything
comes together as I document my process each week.
GitLab Pages
I also worked on setting up GitLab Pages to host my site.
To make GitLab Pages work, I needed to configure the .gitlab-ci.yml file with the
following script:
pages:
stage: deploy
environment: production
script:
- echo 'Nothing to do...'
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
The final step was enabling Shared Runners in the project settings.
To do this, navigate to Settings → CI/CD → Runners and enable "Shared
runners for this project."
Without this, the script won’t function properly.
However, this step was already completed by the instructor due to permission restrictions, so I
didn’t have to configure it manually.
Once the script ran successfully, I found my website URL under Deploy → Pages.
The default URL format is:
https://b242_b3b35jvc.pages.fel.cvut.cz/%USERNAME%
27/02/2025
Precision & Creativity: The Art of Laser Cutting & Stickers
Week 2
Weekly Update
This week, I tackled the three main tasks of the assignment:
Select and measure an object to create a detailed CAD drawing.
Execute a precise cut using a plotter.
Design and laser cut a parametric cardboard building kit.
Throughout, I paid special attention to the kerf (laser beam width) and
fine-tuned
the parameters for clean joints.
OpenSCAD Prototype Casing
I developed a simple, functional prototype casing for my final project using
OpenSCAD. This design
serves as a modular foundation that can be iterated upon quickly, allowing for adjustments based on
material constraints and assembly feedback.
Prototype Casing for Final Project
Plotter Cutting: Vinyl Sticker Preparation
For the plotter cutting task, I focused on the preparation, design, and adjustment of a vinyl
sticker. Using self-adhesive foil, I cut a piece slightly larger than the final desired sticker size
and placed it on the plotter’s base.
Then, using Inkscape I designed an SVG of my sticker—a gear wheel with the text
"JVC" inside,
matching motifs from our course. I converted the design into a vector format and saved it as a
DXF
file. In the plotter software, I simply adjusted the size and sent it to the machine
for cutting.
NOTE: The
cutting plotter only accepts DXF files, not SVG files. When
using Inkscape, please use File → Save As to save your design as a DXF file rather than
using
the Export option.
Simplified Step-by-Step Guide in Inkscape
Step 1: Start with a simple star
Step 2: Add more spikes and circles
Step 3: Unite the shapes
Step 4: Subtract overlapping areas
Final Step: The completed design
After the cutter finished, I peeled off the parts that weren't part of my sticker design and applied
the transfer layer foil.
I was quite pleased with the outcome.
Final result
Laser Cutting: Parametric Cardboard Building Kit
The final task was to design and laser cut a parametric building kit from
cardboard. I developed the design in OpenSCAD and emphasized parameterization so
that the kit can be assembled in multiple configurations.Key variables included the material
thickness and the critical kerf (measured at approximately 0.26
mm), ensuring that
the joints fit tightly and that the final product is both versatile and structurally sound.
I used the OpenSCAD laser
cut
library during the design process.
include <lasercut.scad>;
// Material and panel dimensions
thickness = 2.3;
unit = 70;
center = unit/2 - thickness/2; // roughly the center coordinate
// Define pip sizes
pip_big = unit/12; // a larger pip for a bold accent
pip_small = unit/28; // a smaller pip
// Face 1: "Sunburst" – a big central pip with a ring of 6 small pips
face1_ring = [ for (i = [0:5]) let(angle = i*60)
[ pip_small, center + 15*cos(angle), center + 15*sin(angle) ] ];
face1 = concat( [ [pip_big, center, center] ], face1_ring );
// Face 2: "Diagonal Duo" – two pips of different sizes on opposite corners
face2 = [
[ pip_small, center - 10, center - 10 ],
[ pip_big, center + 10, center + 10 ]
];
// Face 3: "Triangular Triplet" – three pips arranged as an equilateral triangle
face3 = [
[ pip_small, center, center + 10 ],
[ pip_small, center - 10, center - 10 ],
[ pip_small, center + 10, center - 10 ]
];
// Face 4: "Corner Quartet" – four pips placed in the corners with alternating sizes
face4 = [
[ pip_big, center - 15, center + 15 ],
[ pip_small, center + 15, center + 15 ],
[ pip_small, center - 15, center - 15 ],
[ pip_big, center + 15, center - 15 ]
];
// Face 5: "Central & Cardinal" – a dominant center pip with four smaller pips at the edges
face5 = [
[ pip_big, center, center ],
[ pip_small, center, center + 12 ],
[ pip_small, center, center - 12 ],
[ pip_small, center - 12, center ],
[ pip_small, center + 12, center ]
];
// Face 6: "Bar Pattern" – two rows of three pips, alternating big and small for contrast
face6 = [
// Top row
[ pip_big, center - 15, center + 10 ],
[ pip_small, center, center + 10 ],
[ pip_big, center + 15, center + 10 ],
// Bottom row
[ pip_big, center - 15, center - 10 ],
[ pip_small, center, center - 10 ],
[ pip_big, center + 15, center - 10 ]
];
// Gather each face's design into one array (one element per face)
creative_circles_remove_a = [ face1, face2, face3, face4, face5, face6 ];
// Generate the cube with our creative pip patterns removed from each face.
lasercutoutBox(
thickness = thickness,
x = unit, y = unit, z = unit,
sides = 6,
circles_remove_a = creative_circles_remove_a
);
OpenSCAD Editor
I then converted the design into a 2D format so the cutter could process it, using a Python script
from
the library. I had to modify the script slightly to make it compatible with my computer.
Flattened 2D Design
Before starting the laser cutting process, we calibrated the laser. With the FabCore laser
cutter we
used, we placed a calibration puck on the material and lowered the laser head until the beam was
focused perfectly at the center. Then, we measured the kerf by cutting a simple rectangular shape
out
of cardboard and comparing its dimensions with the cut opening, confirming a kerf of around
0.26 mm.
This measurement was essential for designing the interlocking joints.
Prior to the first successful cut, I ran several tests on different sheets of cardboard using a
“trial and error” method to set the optimal balance between laser power and head speed. For our
cardboard, 100% power at 30 m/s proved to be ideal.
Single piece from the kit
Cube prototype
Fully assembled cube
06/03/2025
Prague at Dusk: The Quiet Charm of Charles Square
Week 3
Weekly Update
This week, my assignment included two main tasks:
Scan an object using photogrammetry or a 3D scanner.
Model an object (max 5 cm) that is difficult to produce subtractively.
3D Scanning: Preparation and Process
For this task, our school’s 3D scanners were used. It was crucial to properly prepare the object
for scanning:
All glossy surfaces needed to be coated with a chalk primer to help capture the details
accurately.
The object had to be correctly placed on a stable stand to get the best possible scan.
The scanner was calibrated before use to ensure optimal results.
In my case, I was particularly interested in how a black object would scan. I used a 3D-printed
character made from black filament.
3D Printed Character
3D Scanner Setup with Black Object
As expected, the results weren’t perfect – the scanned object appeared somewhat
rounded rather than sharp, and there were a few imperfections.
However, I was overall satisfied with the outcome given the challenges of
scanning a black surface without any chalk primer.
3D Printing: Parametric Pot for Small Room Plants
I've always wanted to try printing the viral parametric vase—often known as the spiral
vase—but
I decided to create something more useful for me: a parametric pot for my small
room plants. Although there are plenty of tutorials online, I wanted to figure everything out by
myself. The pot’s design is simple, yet functional, and the process was a lot
of fun.
I learned to use Fusion360 Form Modeling, which made the modeling process both
enjoyable and educational.
Fusion360 Create Form
Prusa Slicer Settings
Setting
Value
Layer Height
0.30 mm (Modified Quality)
Filament
Prusa PETG
Printer
Prusa i3 MK3S / MK3S+
Nozzle Size
0.6 mm
Skirt
1 layer (clears extruder residuals)
Wall Width
1 mm
Extrusion Width
0.65 mm
Infill
0% (Spiral Vase mode enabled)
I also experimented with the Spiral Vase option in Prusa
Slicer—which was new to me—and was very
excited to see the results.
Prusa Slicer Settings
Spiral Vase mode
The print went smoothly, as you can see in the timelapse video below. Once finished, I easily
detached the pot from the heatbed. To my surprise, even with such thin walls, the pot turned out
to be quite stiff and durable.
Final result
3D Model
13/03/2025
From Traditional PCB to Flexible Circuits
Week 4
Weekly Update
This week’s assignment focused on creating an electronic circuit that does something simple—like
blinking an LED—without using a solderless breadboard. My chosen path was:
Milling and assembling a simple blinker PCB
Attempting to make a flexible PCB of the same design using a vinyl cutter
Milling and Assembling the PCB
Due to time constraints from other project commitments—and because it can be
relatively complex to
convert a KiCad board design into G-code for our CNC (using CAM software)—I opted for a
pre-prepared
blinking circuit designed by our tutor, Filip Korf. Nonetheless, I recreated the
schematic in
KiCad to fully understand how the astable flip-flop (using two
transistors, resistors, and capacitors) drives alternating LED blinks.
Astable Flip-Flop Schematic by Filip Korf (Recreated in KiCad)
Before milling could begin, we needed to attach the copper board to the CNC work surface using
double-sided adhesive tape. Then, we manually set the starting point for milling.
An automatic leveling (height calibration) was performed to ensure the board was properly aligned.
Milling was carried out in three phases:
Milling the copper layer
Drilling holes for through-hole (THT) components
Cutting out the board
For each phase, we had to manually change the drill bit. We used a
Carvera Air Desktop CNC Machine
in our university lab. After loading the G-code onto an Android tablet running the
CNC software, the milling process began. The process took quite a while to remove the copper, and
once milling was complete, we drilled the component holes and vacuumed away the hazardous debris,
leaving us with a cleanly milled board.
Milled and cleaned PCB
Once the board was milled, I meticulously soldered all the components into place. With my friend off
on an unexpectedly lengthy toilet run, I
had plenty of time on
my hands. So, I channeled my inner perfectionist and went
on a tinningevery joint,
every trace, and even the remaining ground areas. When you're bored, you might as well
give
your PCB the spa treatment it deserves :]
Components
2× Green LED
CR2032 Coin Cell (IKEA brand)
Coin Cell Holder
3-Pin Switch
2× NPN BC337 Transistor SMD
2× 100Ω Resistor SMD
2× 100kΩ Resistor SMD
2× 10μF Capacitor SMD
Assembled Blinker PCB - Front
Assembled Blinker PCB - Back
The finished board blinks as intended, proving how convenient it is to create a simple circuit with
a CNC machine and a few basic components.
Experimenting with a Flexible PCB via
Vinyl Cutter
Next, I took on an extra challenge: making the same circuit on a flexible PCB
using
a vinyl cutter. This approach can be surprisingly accessible, especially for simple circuits. My
process was inspired by various guides and personal experimentation.
Materials and Setup
Conductive Copper Tape (3M 1126 or similar)
Thin PVC Sheet or other flexible substrate
Optional: Kapton Tape to protect the substrate from heat
Vinyl Cutter (e.g., Silhouette Cameo)
Cutting Mat for the vinyl cutter
Electronic components for the circuit
The main idea is to adhere copper tape to a flexible support (e.g., PVC), load it into the vinyl
cutter, and then cut out the circuit traces. One key limitation is the minimum trace
width that the blade can handle.
Step-by-Step Process
Prepare the Substrate: Adhere the copper tape to
the
thin PVC sheet. If
you’re
using Kapton tape, apply it first to protect the PVC from soldering heat.
Attach to Cutting Mat: Stick the PVC sheet (with
copper) onto the cutting
mat,
ensuring there are no bubbles or wrinkles.
Load into Vinyl Cutter: Make sure the rollers
grip the material securely.
Lock
the release lever once it’s in place.
Software Setup:
Import or trace your circuit design in Silhouette Studio (or similar).
Scale the design to match your desired dimensions.
Trace the PNG or vector file so the cutter recognizes the paths.
Weeding: Carefully remove excess copper from
around the traces with
tweezers or
a blade. Ensure the circuit paths remain intact.
Soldering: Use a slightly higher iron
temperature, but minimize contact
time to
avoid melting the substrate. If a trace starts lifting, press it back down or reinforce it
with
a dab of glue.
My Experience and Challenges
I began by converting the Gerber file to an SVG using
this online converter
.
Then, in Inkscape, I adjusted some of the paths to ensure the fills were correct.
Once I was satisfied with the result, I saved the file as a DXF and imported it into
Silhouette Studio.
Silhouette Studio Setup
Copper Tape
After unpacking the tape, I noticed the copper foil had a few bumps. I had to take extra care to
flatten it out before cutting to ensure everything aligned properly.
Bumpy copper foil from packaging
To fine-tune the cutting parameters, I ran small test cuts on a piece of copper foil. I
encountered
issues with the foil shifting when the force was too high—likely due to bumps
or
folds in the tape. After adjusting settings, I finally got a solid result.
Silhouette Studio Parameters
Cutting Parameters
Blade Force: ~6–8
Blade Depth: ~3-4
Speed: 1 cm/s
Number of Passes 10
These values may vary based on your vinyl cutter model and blade condition. The goal is to
cut
through the copper but not through the backing.
Test Cut on Copper Tape
First test cut of the circuit, showing promising but imperfect results
The photo below shows my first attempt at cutting a small circuit on the vinyl cutter,
before I fully tuned the cutting parameters. After carefully
weeding the unwanted copper using tweezers, the remaining traces were
revealed on the mat. You can see one or two broken traces or pads, indicating that
further refinement is needed. Despite these imperfections, this initial test
demonstrates
that the vinyl cutter can handle small circuit designs.
First test cut after weeding leftover copper
After discovering the right cutting parameters, I finally achieved a decent
result with no
broken lines.
Weeding the leftover copper was much easier this time, and the circuit turned out far cleaner
overall.
Weeding tools and finished circuit
Final circuit after parameter tuning
Unfortunately, I didn’t have any proper vinyl-based flexible foil on hand to laminate the
circuit,
so I plan to refine this approach in the future. Still, the experiment was a
success in showing
how
a vinyl cutter can produce simple circuit traces on flexible substrates.
20/03/2025
The Dynamics of Kinetic Structures
Week 5
Weekly Update
This week’s challenge was to design and fabricate a kinetic structure using 3D printing and/or laser
cutting.
The structure needed at least three interconnected components forming a mechanical system.
At first, I struggled to find a suitable idea. Everything I thought
of was either too ambitious or too simple,
and I wanted something intricate yet feasible. After much deliberation, I recalled a video I had
seen a long time ago showcasing a stylized, modern kinetic sculpture inspired by an armillary
sphere.
Modeling: Fusion 360 Assembly & Motion
I decided to model my kinetic sculpture in Fusion 360. My goal
was to create an aesthetic sculpture rather than a functional astronomical tool.
I structured the design as an assembly, ensuring all moving parts were properly
constrained.
To visualize the mechanism, I used motion study tools to animate how the rings
would rotate around a central axis.
3D Model
ANIMATION: Motion Study
Export & Laser Cutting: Precision Matters
Once the design was complete, I exported my sketch as a PDF drawing with high-resolution
interpolation to
maintain smooth curves. The next step was to prepare them for laser cutting using our university’s
Epilog laser cutter.
Laser Cutting Process
Begin by importing DXF/PDF files of the sketches into
the laser software.
Elements intended for cutting, rather than engraving, must have their stroke width set to exactly
0.02 mm. Occasionally, toggling the red laser icon and outline mode in the top-left
panel may be necessary.
To start the cutting process, the user opens the print dialog by pressing Ctrl+P
and navigates to the laser’s configurations, ensuring they match the material preset.
Before initiating the cut, the cutting area can be previewed by lifting the glass lid and pressing
the green test button. Once the placement is confirmed, you can close the lid, activate the exhaust
fan, and the cutting job starts.
I experimented with different power and speed settings to find the
optimal cut for the plywood I was using.
My tip: Refer to the quick-reference table located
next to the machine for recommended settings. It provides a great starting point for various
materials.
Laser Cutting Settings
Power: 100%
Speed: 10%
Frequency: 500 Hz
After some trial and error, I successfully cut all the pieces.
Laser Cutting Done
Assembly: Unexpected Challenges
With all the pieces ready, I moved on to assembly. However, I quickly encountered problems:
The wooden rings were slightly warped due to material imperfections.
Screw holes were not perfectly aligned, leading to misalignment.
Some rings intersected due to minor precision errors, preventing smooth
rotation.
Despite these setbacks, I tried to adjust the components, but the structure still didn’t rotate as
intended.
Unexpected Result
Plan B: Simplified Home-Built Version
Instead of giving up, I decided to salvage the project by creating a simplified version using
materials I had at home.
The new design was much easier to assemble and, surprisingly, quite amusing to
watch.
In the end, despite the issues with precision and material choice, I was quite
satisfied with the result.
The final kinetic sculpture may not have been perfect, but it still managed to capture the
essence of my original vision. :]
27/03/2025
Making Lights Dance to Music!
Week 6
Weekly Update
This week's objective was to interface a sensor with an Arduino to measure a physical quantity,
calibrate it, and present the findings. The task required using a sensor from the JVC kit or
available loaner components.
Since I had some cool parts lying around gathering dust, I decided to build something I've always
wanted: an Audio Spectrum Analyzer. You know, those flashy lights that bounce to
the music? Yep, that's the plan! Listen to the room, figure out the beats and melodies, and make an
LED matrix dance accordingly.
The Gear: My Chosen Weapons
To pull this off, I raided my parts bin for:
Arduino Nano
MAX9814 Microphone Module: Not just any mic! This little guy has a built-in amp
with Automatic Gain Control (AGC). Translation: it tries to automatically
adjust for loud vs. quiet sounds, saving me a headache. Plus, selectable gain and some noise
filtering? Yes, please.
WS2812B 8x8 Neopixel Matrix
Having these already at home meant zero waiting time and maximum
tinkering time. Win-win!
Focus On: The MAX9814 Microphone Module
This specialized integrated circuit combines a low-noise preamplifier,
a variable gain amplifier (VGA), and control circuitry onto a single chip, often mounted on a
convenient breakout board with an included electret microphone.
The MAX9814 Microphone Module
Key Features:
Automatic Gain Control (AGC): This is the standout feature. The AGC circuitry
automatically adjusts the internal amplification level. It reduces the gain for loud sounds to
prevent distortion (clipping) and increases the gain for quiet sounds, aiming to maintain a
relatively constant output signal level.
Selectable Maximum Gain: While the AGC adjusts gain dynamically, the
maximum potential gain can be set via the 'Gain' pin. This allows tailoring the
module's sensitivity baseline.
Adjustable Attack/Release Ratio: The 'AR' pin allows modification of the AGC's
response timing – how quickly it reacts to changes in sound level (attack) and how quickly it
returns to the baseline gain after a loud sound stops (release).
Low-Noise Design: The module incorporates a low-noise preamplifier and internal
microphone bias voltage generator, contributing to clearer audio capture with reduced background
hiss.
Pin Configuration:
The breakout board used in this project provides access to the following essential pins:
Pin Name
Description
Vdd (or VCC)
Power Supply Input (typically 2.7V to 5.5V DC)
GND
Ground Reference (0V)
Out
Analog Output Signal. This signal represents the amplified sound level, typically
biased around a DC voltage (e.g., 1.25V).
Gain
Input for setting the maximum amplifier gain level.
AR
Input for setting the AGC Attack/Release ratio.
Adjustable Settings via Pins:
The Gain and AR pins are typically configured by connecting them to Vdd, GND, or leaving them
unconnected (floating). The specific settings can vary slightly between module implementations, but
common configurations are:
Gain Pin Configuration
Gain Pin Connection
Resulting Maximum Gain
Floating (Unconnected)
60dB (Highest)
Connected to GND
50dB (Medium)
Connected to Vdd
40dB (Lowest)
AR Pin Configuration
AR Pin Connection
Resulting Attack/Release Ratio
Floating (Unconnected)
1:4000 (Slowest response, default)
Connected to Vdd
1:2000 (Medium response)
Connected to GND
1:500 (Fastest response)
Note: In this specific project build, the Gain pin was connected to Vdd, setting the maximum
gain to 40dB, and the AR pin was left floating for the default 1:4000 ratio.
The MAX9814 module significantly simplifies the audio input stage by providing amplification and
level management, allowing the microcontroller (Arduino) to focus on processing the resulting analog
signal for analysis and visualization.
Wiring it Up: Connecting the Dots
Time to connect the bits. It looks something like this (don't worry, it's less scary than it
sounds):
The Master Plan (Circuit Diagram)
Connection Cheat Sheet:
Power (Super Important!): We need an external 5V power
supply. The LEDs are power-hungry beasts and the Arduino's USB port won't cut it.
This external supply feeds both the Arduino (via VIN/5V pin) and the LED Matrix (VCC/GND). The
Mic module also sips power from the Arduino's 5V output.
A 22uF capacitor is placed across the Mic module's VCC and GND for
power smoothing – helps keep the mic happy.
MAX9814 Mic Module:
Vdd (or VCC) to Arduino 5V.
GND to Arduino GND.
Out (Audio Signal) goes through a 10nF capacitor (marked
103) to Arduino Analog Pin A0. This capacitor blocks any DC
offset from the mic, letting only the AC audio signal through.
Gain pin is tied directly to Vdd (5V). For the MAX9814, this
usually sets the gain to the lowest setting (e.g., 40dB), which is often a good starting
point. We can adjust amplification in software later if needed.
WS2812B LED Matrix:
VCC to the external 5V power supply.
GND to the external power supply's GND (and make sure this GND
is also connected to the Arduino's GND!).
DIN (Data In) connects to Arduino Digital Pin D6 via a
220 ohm resistor. This little guy is surprisingly important!
Its main job is to improve signal integrity by dampening signal
reflections ('ringing'), ensuring the data gets to the first LED cleanly,
preventing flickers or weird colors.
It also adds a layer of protection for the first LED's delicate data
input pin against potential voltage spikes.
As a bonus safety measure, it protects the Arduino's output pin
by limiting the current if you happen to connect GND and DIN *before* connecting
the main +5V power to the LEDs. This prevents the Arduino pin from trying to
power the unpowered strip through the data line (which could damage the
Arduino).
Software Sorcery: Turning Sound into Light
Making it blink to the beat requires some code magic. Here's the basic recipe:
Listen Closely (Audio Sampling): Grab the audio signal from the mic
module via
pin A0 using analogRead(). We need to do this thousands of
times per
second to
catch the details in the sound.
Break it Down (Frequency Analysis - FHT): This is the cool part. We
need to
figure out "how much" of each frequency (low bass, mid-range, high treble) is in the
sound
sample. The classic way is the Fast Fourier Transform (FFT). But FFTs can be slow on
little
Arduinos. I found a nifty alternative: the Fast Hartley Transform
(FHT). It's
related to the FFT but can be speedier for real-world signals like audio from a mic on
simpler
processors. I used the arduinoFHT library, which is optimized
for this kind of job, as
it is recommended by the author of a popular Arduino FFT library for handling such tasks
efficiently.
Faster code = smoother lights!
Paint the Picture (Mapping & Visualization): The FHT gives us numbers
representing the intensity of different frequency ranges (or "bins"). Now we map these
numbers
to the LED grid:
Each column on the 8x8 matrix represents a specific frequency
band
(e.g., bass on the left, treble on the right).
The height of the lit LEDs in a column shows how loud that
frequency
band is.
We can even use color to make it fancier – maybe different
colors for
different columns/frequencies, and brighter shades for louder sounds.
Calibration Corner
"Calibration" here isn't about scientific accuracy, it's about making it *look good*. This
involved
tweaking values in the code:
Sensitivity/Noise Floor: Adjusting how quiet a sound needs to be before
the
lights react (the noiseThreshold in the code). Too sensitive, and it reacts
to
everything; too
insensitive, and it misses quiet parts.
Gain/Amplification: Boosting the incoming signal
(signalAmplification) if the
mic pickup seems too low, or reducing it if everything maxes out too easily.
Frequency Mapping: Deciding exactly which FHT bins map to which columns
(FREQUENCY_BANDS).
Visual Flair: Tuning the smoothing (barSmoothness), peak
dot behavior
(PeakDotsConfig), colors (COLUMN_PALETTE), and overall
brightness. It's more art
than
science!
(Essentially: Fiddle with numbers until the blinky lights look awesome.)
The Grand Reveal: It's Alive!
Success! After uploading the code and playing some tunes, the matrix sprang to life. Bass
hits make
the low-frequency columns jump, while higher notes and voices light up the other side. The
height
tracks the loudness, creating a mesmerizing visual beat. It actually works!
Bouncing to Dire Straits
Trying Some Piano Tunes
The Finished Gadget
FULL CODE
#include <FastLED.h>
#define LOG_OUT 1
#include <FHT.h>
#define LED_PIN 6
#define AUDIO_INPUT_PIN 0 // A0
// ------------------------- Configuration Structures -------------------------
struct DisplayConfig
{
const uint8_t width = 8; // Matrix width (number of LEDs)
const uint8_t height = 8; // Matrix height (number of LEDs)
const uint8_t brightness = 8; // LED brightness (0-255)
};
struct AudioConfig
{
const float signalAmplification = 1.3; // Input signal amplification factor
const uint8_t noiseThreshold = 30; // Lower noise sensitivity threshold
const float peakFactor = 1.05; // Coefficient for peak scaling
};
struct AnimationConfig
{
const float barSmoothness = 0.4; // Bar movement smoothness (0-1)
const uint8_t refreshDelay = 4; // Display refresh delay in milliseconds
};
struct PeakDotsConfig
{
const bool enabled = true; // Enable peak dots display
const uint16_t fallDelay = 50; // Delay (ms) between peak falls
const uint16_t fallPause = 700; // Time (ms) to hold peak before falling
const uint8_t hueShift = 45; // Hue shift for peak dots
};
struct PinConfig
{
const uint8_t audioInput = AUDIO_INPUT_PIN; // Audio input pin
};
// Frequency band mapping (approximately parabolic: 80Hz to 16kHz)
const uint8_t FREQUENCY_BANDS[9] = {2, 6, 10, 14, 20, 30, 60, 100, 120};
// FFT configuration
const uint16_t FFT_SAMPLES = 256;
// Column color palette (left-to-right)
const CRGB COLUMN_PALETTE[8] = {
CRGB(160, 80, 0), // Brownish/Orange
CRGB(184, 120, 0), // Orange-gold
CRGB(168, 168, 0), // Olive
CRGB(64, 184, 0), // Green
CRGB(0, 184, 64), // Teal
CRGB(0, 184, 184), // Cyan
CRGB(0, 120, 184), // Blue
CRGB(0, 64, 184) // Deep Blue
};
// ADC register manipulation macros
#define setRegisterBit(sfr, bit) (_SFR_BYTE(sfr) |= _BV(bit))
#define clearRegisterBit(sfr, bit) (_SFR_BYTE(sfr) &= ~_BV(bit))
// ------------------------- AudioVisualizer Class -------------------------
class AudioVisualizer
{
private:
// Configuration objects
DisplayConfig displayCfg;
AudioConfig audioCfg;
AnimationConfig animCfg;
PeakDotsConfig peakCfg;
PinConfig pinCfg;
// LED array
CRGB *leds;
uint16_t numLeds;
// Audio processing variables
int currentGain;
uint8_t maxAmplitude;
float amplitudeSmoothing;
float smoothedMaxAmplitude;
// Peak tracking arrays for each column (0 to displayCfg.width-1)
int peakLevels[8];
uint8_t previousLevels[8];
unsigned long peakTimestamps[8];
// Timing variables
unsigned long gainUpdateTimer;
unsigned long peakFallTimer;
unsigned long lastRefreshTime;
bool peakFallTrigger;
public:
AudioVisualizer()
{
numLeds = displayCfg.width * displayCfg.height;
leds = new CRGB[numLeds];
amplitudeSmoothing = 0.05;
smoothedMaxAmplitude = 0.0;
for (uint8_t i = 0; i < displayCfg.width; i++)
{
peakLevels[i] = 0;
previousLevels[i] = 0;
peakTimestamps[i] = 0;
}
gainUpdateTimer = 0;
peakFallTimer = 0;
lastRefreshTime = 0;
peakFallTrigger = false;
}
~AudioVisualizer()
{
delete[] leds;
}
void setup()
{
// Increase ADC sampling frequency
setRegisterBit(ADCSRA, ADPS2);
clearRegisterBit(ADCSRA, ADPS1);
setRegisterBit(ADCSRA, ADPS0);
// Optionally, set analogReference(EXTERNAL);
FastLED.setBrightness(displayCfg.brightness);
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, numLeds);
}
// Capture FFT_SAMPLES audio samples and process the FFT
void captureAndProcessAudio()
{
for (uint16_t i = 0; i < FFT_SAMPLES; i++)
{ // Loop variable as uint16_t
fht_input[i] = analogRead(pinCfg.audioInput);
}
fht_window();
fht_reorder();
fht_run();
fht_mag_log();
}
// Filter, amplify, and normalize the FFT results
void processFrequencyData()
{
for (uint16_t i = 0; i < 128; i++)
{
if (fht_log_out[i] < audioCfg.noiseThreshold)
{
fht_log_out[i] = 0;
}
fht_log_out[i] = static_cast<float>(fht_log_out[i]) * audioCfg.signalAmplification;
}
}
// Render the LED matrix visualization based on frequency data
void renderVisualization()
{
maxAmplitude = 0;
FastLED.clear();
for (uint8_t column = 0; column < displayCfg.width; column++)
{
int columnLevel = fht_log_out[FREQUENCY_BANDS[column]];
// Sum contributions from adjacent frequency bins for a smoother transition
if (column > 0 && column < displayCfg.width)
{
uint8_t lowerBandWidth = FREQUENCY_BANDS[column] - FREQUENCY_BANDS[column - 1];
for (uint8_t i = 0; i < lowerBandWidth; i++)
{
float weight = static_cast<float>(i) / lowerBandWidth;
columnLevel += weight * fht_log_out[FREQUENCY_BANDS[column] - lowerBandWidth + i];
}
uint8_t upperBandWidth = FREQUENCY_BANDS[column + 1] - FREQUENCY_BANDS[column];
for (uint8_t i = 0; i < upperBandWidth; i++)
{
float weight = static_cast<float>(i) / upperBandWidth;
columnLevel += weight * fht_log_out[FREQUENCY_BANDS[column] + upperBandWidth - i];
}
}
// Update global max amplitude for gain adjustment
if (columnLevel > maxAmplitude)
{
maxAmplitude = columnLevel;
}
// Smooth the column level for fluid animation
int smoothedLevel = columnLevel * animCfg.barSmoothness + previousLevels[column] * (1 - animCfg.barSmoothness);
previousLevels[column] = smoothedLevel;
// Scale the smoothed level to the display height
smoothedLevel = map(smoothedLevel, audioCfg.noiseThreshold, currentGain, 0, displayCfg.height);
smoothedLevel = constrain(smoothedLevel, 0, displayCfg.height);
// Render the column with a vertical brightness gradient
renderColumn(column, smoothedLevel);
// Update and render the peak dot for this column
updatePeakLevel(column, smoothedLevel);
renderPeakDot(column);
updatePeakFalling(column);
}
FastLED.show();
}
// Render a single column with a vertical gradient
void renderColumn(uint8_t column, int height)
{
if (height <= 0)
return;
for (uint8_t row = 0; row < height; row++)
{
CRGB baseColor = COLUMN_PALETTE[column];
uint8_t brightness = map(row, 0, displayCfg.height - 1, 70, 255);
CRGB pixelColor = baseColor;
pixelColor.nscale8(brightness);
leds[column * displayCfg.height + row] = pixelColor;
}
}
// Update the peak level for the given column
void updatePeakLevel(uint8_t column, int level)
{
if (level > 0 && level > peakLevels[column])
{
peakLevels[column] = level;
peakTimestamps[column] = millis();
}
}
// Render the peak dot using a hue-shifted version of the base color
void renderPeakDot(uint8_t column)
{
if (peakLevels[column] >= 0 && peakCfg.enabled)
{
CRGB peakColor = COLUMN_PALETTE[column];
uint8_t brightness = map(peakLevels[column], 0, displayCfg.height - 1, 70, 255);
peakColor.nscale8(brightness);
// Convert to HSV and shift hue by the configured hueShift for a darker/different appearance
CHSV hsvColor = rgb2hsv_approximate(peakColor);
hsvColor.hue += peakCfg.hueShift;
peakColor = hsvColor;
peakColor.nscale8(brightness);
leds[column * displayCfg.height + peakLevels[column]] = peakColor;
}
}
// Make peaks fall after holding for a specified time
void updatePeakFalling(uint8_t column)
{
if (peakFallTrigger)
{
if (millis() - peakTimestamps[column] > peakCfg.fallPause)
{
if (peakLevels[column] >= 0)
{
peakLevels[column]--;
}
}
}
}
// Update the audio gain based on the maximum amplitude with smoothing
void updateGain()
{
if (millis() - gainUpdateTimer > 10)
{
smoothedMaxAmplitude = maxAmplitude * amplitudeSmoothing + smoothedMaxAmplitude * (1 - amplitudeSmoothing);
currentGain = (smoothedMaxAmplitude > audioCfg.noiseThreshold) ? (audioCfg.peakFactor * smoothedMaxAmplitude) : 100;
gainUpdateTimer = millis();
}
}
// Update the trigger for peak falling
void updatePeakFallTimer()
{
peakFallTrigger = false;
if (millis() - peakFallTimer > peakCfg.fallDelay)
{
peakFallTrigger = true;
peakFallTimer = millis();
}
}
// Main loop: capture audio, process it, update visualization and gain
void loop()
{
if (millis() - lastRefreshTime > animCfg.refreshDelay)
{
lastRefreshTime = millis();
captureAndProcessAudio();
processFrequencyData();
renderVisualization();
updateGain();
updatePeakFallTimer();
}
}
};
// ------------------------- Main Program -------------------------
AudioVisualizer visualizer;
void setup()
{
visualizer.setup();
}
void loop()
{
visualizer.loop();
}
Wrapping Up: Lessons Learned
This was a super fun project! It nicely tied together sensor input (the mic), data
processing (the
FHT), and visual output (the LEDs). The MAX9814 mic made the hardware side easier, and the
FHT
library kept the Arduino from melting (metaphorically). Getting the sensitivity and visuals
tuned
just right took some patience, but seeing the lights dance to music? Totally worth it! :]
03/04/2025
Resonant Bodies: How Machines Speak Through Motion and Light
Week 7
Weekly Update
This week shifted focus to interactive output devices. The assignment
involved selecting and controlling an output device not previously used, and integrating it with an
input mechanism.
Connect and utilize an output device not used previously in the course.
Design a program linking at least one input and one output device.
For this, I utilized the LILYGO T5 V2.3.1 development board, which conveniently
integrates an ESP32 microcontroller with a 2.13-inch E-Paper Display
(EPD). I explored controlling the EPD using the GxEPD2 library,
including generating dynamic QR codes. The input mechanism was implemented using
Bluetooth Low Energy (BLE), allowing commands to be sent wirelessly to update the
display.
Focusing on the E-Paper display and BLE for this week's assignment was a strategic choice, as it
feeds directly into my final project. It was great to be able to use this assignment to build out a
core piece of the digital badge.
The LILYGO T5 V2.3.1 Board
Configuring the GxEPD2 Library for the Display
E-paper displays require specific drivers due to their unique electrophoretic technology. The GxEPD2 library
supports many different EPD panels, but you must tell it exactly which one you are using and how
it's connected to the ESP32 (via SPI pins).
The recommended way to configure the library for your specific display and board is
not to edit files directly within the installed library folder (as updates would
overwrite your changes). Instead, the library provides convenient configuration header files within
its example sketches. These files simplify the selection process.
The correct approach is to:
Navigate to the GxEPD2 library's installation directory (usually within your Arduino
libraries
folder).
Open the examples subfolder.
Choose a relevant base example sketch folder (e.g., GxEPD2_Example).
Identify the main configuration header file (often
GxEPD2_display_selection_new_style.h) and its necessary companion files (like
GxEPD2_selection_check.h and potentially GxEPD2_wiring_examples.h)
within that single example folder.
Copy these specific header files (typically 2 or 3 files) from that chosen
example folder into your own Arduino sketch's main folder (the same directory where
your .ino or main .cpp file resides).
When you include the main configuration header (e.g.,
#include "GxEPD2_display_selection_new_style.h") in your sketch and build your project, the
Arduino IDE (or your build environment) will automatically use your local copies and find the included
companion files (like GxEPD2_selection_check.h) in the same directory. These local files
contain all
the necessary #define statements for selecting your display driver and pins.
Project Directory Structure Example:
MyEPaperProject/
├── MyEPaperProject.ino <-- Your main sketch file
├── GxEPD2_display_selection_new_style.h <-- Copied & edited configuration from an example
├── GxEPD2_selection_check.h <-- Copied dependency from the same example
└── GxEPD2_wiring_examples.h <-- Optional: Copied if present/needed by selection file
Once you have these configuration files copied into your project directory, you can safely
edit your local copy of the main selection file (e.g.,
GxEPD2_display_selection_new_style.h).
The first step remains identifying the specific E-Paper panel model or controller chip used
on your board. Look for markings on the board's silkscreen, check the documentation,
or inspect the display's flexible flat cable (FPC).
Identifying the E-Paper panel via the FPC label (e.g., FPC-A002)
As seen in the image above, the FPC cable might have a label like FPC-A002. This code,
along with
the display size (2.13 inches) and potentially the board's schematic or documentation, helps
identify the correct EPD controller (e.g., GDEH0213B72, GDEH0213B73, etc.). Once identified, you
need to find the corresponding #define GxEPD2_DRIVER_CLASS line in your copied
main selection header file
and uncomment it (remove the leading //), ensuring only one driver class is active.
Then, scroll down further in the same file to the section for pin definitions (often within
#if defined(...) blocks for specific boards) and uncomment or adjust the lines
corresponding to your specific board (like the LILYGO T5 ESP32 variant), ensuring the SPI pins
(SCK, MOSI, CS, DC, RST,
BUSY) match your hardware connections. These pin definitions might be set directly or
via a constructor call in the example's .ino file, which you might need to adapt in
your own sketch.
Example Snippet from your copiedGxEPD2_display_selection_new_style.h
// This file needs GxEPD2_selection_check.h in the same directory
#include "GxEPD2_selection_check.h"
// --- Select display class ---
#define GxEPD2_DISPLAY_CLASS GxEPD2_BW
// ... other display classes commented out ...
// --- Select display driver class ---
// ... other drivers commented out ...
#define GxEPD2_DRIVER_CLASS GxEPD2_213_GDEY0213B74 // GDEY0213B74 122x250, SSD1680, (FPC-A002 20.04.08) <-- UNCOMMENTED FOR THIS EXAMPLE
// ... other drivers commented out ...
// --- Pin Configuration Section (often board-specific) ---
// ... many #if defined(...) sections ...
#if defined(ARDUINO_ARCH_ESP32) // Example block for ESP32
// ... different board wirings commented out ...
// Wiring for LILYGO T5 V2.3.1 (example, verify your board!)
// This might be set via constructor in the .ino file instead of defines here.
// Check the example you copied from. If pins ARE defined here, uncomment the correct block:
// Example GxEPD2_DISPLAY_CLASS<...> display(GxEPD2_DRIVER_CLASS(/*CS=5*/ EPD_CS, /*DC=*/ 17, /*RST=*/ 16, /*BUSY=*/ 4)); // Common wiring
#endif
// ... more board sections ...
The library provides standard graphics functions
(display.print,
display.drawRect, etc.) and handles the complex update sequences.
Partial vs. Full Updates
A key feature of many EPDs supported by GxEPD2 is partial update
capability.
Full Update: Refreshes the entire screen. Necessary for complex changes or to
eliminate ghosting. Causes a noticeable black/white flashing cycle.
Partial Update: Refreshes only a defined rectangular portion. Much faster,
avoids flashing, uses less power. Ideal for small, frequent changes (e.g., status text, sensor
values).
Code Snippet: Partial Update Concept
// --- Concept: Using Partial Update ---
// Define the area
const int PARTIAL_AREA_X = 50, PARTIAL_AREA_Y = 40, PARTIAL_AREA_W = 150, PARTIAL_AREA_H = 30;
char statusBuffer[40]; // Buffer for text
void showStatus(const char* message) {
snprintf(statusBuffer, sizeof(statusBuffer), "%s", message); // Copy message
display.setPartialWindow(PARTIAL_AREA_X, PARTIAL_AREA_Y, PARTIAL_AREA_W, PARTIAL_AREA_H);
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE); // Clear *only* the partial window
// ... code to set font, calculate position, and display.print(statusBuffer) ...
} while (display.nextPage());
display.hibernate();
}
Generating and Displaying QR Codes
QR codes provide a compact way to share information like URLs or contact details. To generate them
on the ESP32 for display on the E-Paper, I integrated a QR code generation library (like
qrcode.h
derived from libqrcodegen).
The Generation Process
Generating a QR code involves several steps handled by the library:
Input Data: The text or URL to be encoded is provided to the library.
Encoding Mode: The library typically selects the most efficient encoding
(numeric, alphanumeric, byte, Kanji) based on the input data.
Version Selection: A QR code Version (1-40) is determined,
often
automatically or specified by the user. Higher versions mean a larger grid size (more modules)
and higher data capacity.
Error Correction Level (ECC): A level (Low, Medium, Quartile, High) is chosen.
Higher ECC allows the code to be read even if partially damaged or obscured, but reduces data
capacity.
Module Calculation: The library performs complex calculations based on the QR
standard to determine the pattern of black and white squares (modules)
representing the
data, encoding mode, version, ECC level, and required finder/alignment patterns.
Buffer Output: The final module pattern (a grid of boolean values or similar)
is typically stored in a memory buffer (e.g., a uint8_t array).
Drawing the QR Code
Once the module pattern is in the buffer:
The E-Paper display is prepared for a full update.
The screen buffer is cleared (usually to white).
The code iterates through the QR module buffer.
For each module marked as "black" in the buffer, a filled rectangle
(display.fillRect) is
drawn on the screen buffer.
The size and position of this rectangle are determined by a chosen scale factor
(how many
pixels wide/high each module should be) and calculated offsets to center the QR code on the
display.
After iterating through all modules, the screen buffer is sent to the E-Paper display.
QR Code Generated and Shown on the LILYGO T5
Integrating Bluetooth Input with E-Paper Output
To fulfill the task of linking input and output, I used the ESP32's built-in Bluetooth Low Energy
(BLE) capability as the input mechanism to control the E-Paper display (output). A simple BLE
service was created with a single writable characteristic.
Using a BLE mobile app (like nRF Connect or LightBlue), commands can be sent to this characteristic:
Sending a URL string triggers the ESP32 to generate the corresponding QR code
and display it using a full screen update.
Sending the specific text command "clear" triggers the ESP32 to clear the
E-Paper display using a full screen update.
Connection status messages ("Connected", "Disconnected", "Waiting...") or error messages ("Data
Too Long") are displayed using efficient partial updates in a central area of the screen.
Final Integrated Code
The following code implements the BLE service, QR generation, E-Paper drawing logic with partial/full
update handling, and command parsing:
/**
* @file BleQrBadge_SingleChar_PartialText_V2.ino
* @brief E-Paper badge: Displays QR code from BLE URL via a single characteristic.
* Uses partial updates for text status, full updates for QR/Clear.
* Command "clear" on the same characteristic clears the screen.
* Landscape & Centered. Single Service/Characteristic.
* @author Amir Akrami
*/
// Core GxEPD2 library
#include <GxEPD2_BW.h>
// Font library for status messages
#include <Fonts/FreeSans9pt7b.h> // Using 9pt font
// QR Code Generation Library
#include <qrcode.h>
// Include Arduino core
#include <Arduino.h>
// === IMPORTANT: Display Configuration Header ===
// Selected display type is in this file
#include "GxEPD2_display_selection_new_style.h"
// === IMPORTANT ===
// --- BLE Includes ---
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#include <BLECharacteristic.h>
#include <BLEAdvertising.h>
// ===================================================================================
// Configuration Constants
// ===================================================================================
// --- QR Code Configuration ---
const int FIXED_QR_VERSION = 7;
const int FIXED_QR_SCALE = 2;
const int MAX_INPUT_STRING_LENGTH = 90;
const int QR_QUIET_ZONE_MODULES = 4;
// --- BLE Configuration ---
// TODO: Generate my own unique UUIDs for production!
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define DATA_CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" // Single characteristic for URL or "clear" command
const char* bleDeviceName = "EPaper QR Badge";
// --- Partial Update Configuration ---
const int STATUS_AREA_WIDTH = 200;
const int STATUS_AREA_HEIGHT = 50;
int STATUS_AREA_X = 0;
int STATUS_AREA_Y = 0;
// ===================================================================================
// Global Variables
// ===================================================================================
// --- Display ---
// 'display' object created in GxEPD2_display_selection_new_style.h
// --- Data Handling ---
String currentDataString = "";
bool newQrDataReceived = false;
bool clearDisplayRequested = false;
bool statusUpdateRequested = false;
String statusMessageToShow = "";
// *** NEW FLAG ***: Tracks if the screen needs a full clear before the next status message
bool needsFullClearBeforeNextStatus = true; // Start true to ensure first status message clears screen properly
// --- BLE ---
BLEServer *pServer = NULL;
BLECharacteristic *pDataCharacteristic = NULL;
bool deviceConnected = false;
// ===================================================================================
// Function Prototypes
// ===================================================================================
void setupBLE();
void showStatusMessage(const char* message); // Uses PARTIAL update
void updateQrDisplay(const char* textToEncode); // Uses FULL update
void drawQrScreen(const char* textToEncode); // Draws content for QR full update
void clearDisplay(); // Uses FULL update (blank screen)
void performQuickFullClear(); // Helper for full clear without hibernation
void drawCenteredText(const char *text, int baselineY, const GFXfont *font, int targetW = -1, int targetX = 0);
bool drawQrCode(int x_target_area, int y_target_area, int w_target_area, int h_target_area, const char *text);
// ===================================================================================
// BLE Callback Classes
// ===================================================================================
// --- Server Connection Callbacks ---
class MyServerCallbacks: public BLEServerCallbacks {
void onConnect(BLEServer* pServer) {
deviceConnected = true;
Serial.println("BLE Client Connected");
statusMessageToShow = "Connected.\nWaiting for data...";
statusUpdateRequested = true;
}
void onDisconnect(BLEServer* pServer) {
deviceConnected = false;
Serial.println("BLE Client Disconnected");
statusMessageToShow = "Disconnected.\nAdvertising...";
statusUpdateRequested = true;
delay(500);
BLEDevice::startAdvertising();
Serial.println("Advertising restarted");
}
};
// --- Data Characteristic Write Callback ---
class DataCharacteristicCallbacks: public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pCharacteristic) {
std::string value = pCharacteristic->getValue();
String valueStr = String(value.c_str());
valueStr.trim();
Serial.print("Received Value: '");
Serial.print(valueStr);
Serial.println("'");
if (valueStr.equalsIgnoreCase("clear")) {
Serial.println("Clear command recognized.");
clearDisplayRequested = true;
newQrDataReceived = false;
statusUpdateRequested = false;
}
else if (valueStr.length() > 0) {
if (valueStr.length() > MAX_INPUT_STRING_LENGTH) {
Serial.printf("Error: Received data length (%d) exceeds maximum (%d)\n", valueStr.length(), MAX_INPUT_STRING_LENGTH);
statusMessageToShow = "Error: Data Too Long";
statusUpdateRequested = true; // Trigger status update
newQrDataReceived = false;
clearDisplayRequested = false;
} else {
Serial.println("New QR data received.");
currentDataString = valueStr;
newQrDataReceived = true;
clearDisplayRequested = false;
statusUpdateRequested = false;
}
} else {
Serial.println("Received empty value. Ignoring.");
}
}
};
// ===================================================================================
// Setup Function
// ===================================================================================
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 2000);
Serial.println("\nStarting BLE QR Badge (Single Char, Partial Text V2)");
// --- Initialize Display ---
display.init(115200);
Serial.println("Display initialized");
display.setRotation(1);
int screenW = display.width();
int screenH = display.height();
Serial.printf("Display rotation: %d. Width: %d, Height: %d\n", display.getRotation(), screenW, screenH);
// --- Calculate Status Area Position ---
STATUS_AREA_X = (screenW - STATUS_AREA_WIDTH) / 2;
STATUS_AREA_Y = (screenH - STATUS_AREA_HEIGHT) / 2;
if (STATUS_AREA_X < 0) STATUS_AREA_X = 0;
if (STATUS_AREA_Y < 0) STATUS_AREA_Y = 0;
Serial.printf("Status message area (for partial update): x=%d, y=%d, w=%d, h=%d\n",
STATUS_AREA_X, STATUS_AREA_Y, STATUS_AREA_WIDTH, STATUS_AREA_HEIGHT);
// --- Initial Screen Clear (Full Update) ---
Serial.println("Performing initial full screen clear.");
performQuickFullClear(); // Use helper to clear without hibernating yet
needsFullClearBeforeNextStatus = false; // Screen is now clean, reset flag
// --- Show Initial Message (using partial update) ---
statusMessageToShow = "Initializing BLE...";
statusUpdateRequested = true; // Trigger initial status update in loop
// --- Initialize BLE ---
setupBLE();
// --- Update Waiting Message ---
statusMessageToShow = "Waiting for Connection...";
statusUpdateRequested = true; // Trigger update in loop
Serial.println("Setup complete. Advertising...");
}
// ===================================================================================
// Loop Function
// ===================================================================================
void loop() {
// --- Handle Display Actions based on Flags (Priority: Clear > QR > Status) ---
if (clearDisplayRequested) {
clearDisplayRequested = false; // Reset flag *before* action
Serial.println("Processing 'clear' command...");
clearDisplay(); // FULL update to blank screen
Serial.println("Display cleared via BLE command.");
needsFullClearBeforeNextStatus = true; // Set flag: next status needs full clear
display.hibernate(); // Hibernate after FULL update
}
else if (newQrDataReceived) {
newQrDataReceived = false; // Reset flag *before* action
Serial.println("Processing new QR data...");
updateQrDisplay(currentDataString.c_str()); // FULL update (draws QR or error)
Serial.println("QR display update attempt complete.");
needsFullClearBeforeNextStatus = true; // Set flag: next status needs full clear
display.hibernate(); // Hibernate after FULL update
}
else if (statusUpdateRequested) {
statusUpdateRequested = false; // Reset flag *before* action
Serial.println("Processing status message update...");
// *** CHECK if full clear is needed before this partial update ***
if (needsFullClearBeforeNextStatus) {
Serial.println("...Performing full screen clear before status update (previous update was full).");
performQuickFullClear(); // Clear the whole screen
needsFullClearBeforeNextStatus = false; // Reset the flag
}
// Now proceed with the partial update for the status message
showStatusMessage(statusMessageToShow.c_str()); // PARTIAL update
Serial.println("Status message updated.");
// Note: needsFullClearBeforeNextStatus remains false after a partial update
display.hibernate(); // Hibernate after PARTIAL update
}
// --- Allow BLE stack time to process ---
delay(100);
}
// ===================================================================================
// BLE Setup Function
// ===================================================================================
void setupBLE() {
Serial.println("Initializing BLE...");
BLEDevice::init(bleDeviceName);
pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks());
BLEService *pService = pServer->createService(SERVICE_UUID);
pDataCharacteristic = pService->createCharacteristic(
DATA_CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_WRITE
);
pDataCharacteristic->setCallbacks(new DataCharacteristicCallbacks());
// Add a User Description Descriptor (UUID 0x2901)
// This is **highly recommended** for debugging with generic BLE tools (e.g., nRF Connect)
// It allows tools to show a human-readable name for the characteristic.
// While not strictly essential for function if using a dedicated app, it aids development greatly.
BLEDescriptor* pDataDesc = new BLEDescriptor(BLEUUID((uint16_t)0x2901));
pDataDesc->setValue("URL data or 'clear' command");
pDataCharacteristic->addDescriptor(pDataDesc);
pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(true);
BLEDevice::startAdvertising();
Serial.println("BLE Service Started, Advertising.");
Serial.printf("Device Name: %s\n", bleDeviceName);
Serial.printf("Service UUID: %s\n", SERVICE_UUID);
Serial.printf(" Data Characteristic: %s (Write URL or 'clear')\n", DATA_CHARACTERISTIC_UUID);
}
// ===================================================================================
// Show Status Message Function (PARTIAL UPDATE)
// ===================================================================================
void showStatusMessage(const char* message) {
// (Function remains the same as previous version)
if (!message || message[0] == '\0') {
Serial.println("showStatusMessage: Skipping empty message.");
return;
}
Serial.printf("Display Status (Partial Update in area %d,%d %dx%d): %s\n",
STATUS_AREA_X, STATUS_AREA_Y, STATUS_AREA_WIDTH, STATUS_AREA_HEIGHT, message);
display.setPartialWindow(STATUS_AREA_X, STATUS_AREA_Y, STATUS_AREA_WIDTH, STATUS_AREA_HEIGHT);
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE); // Clear only the partial window
display.setFont(&FreeSans9pt7b);
int16_t x1, y1; uint16_t w, h;
const char* newline = strchr(message, '\n');
if (newline != NULL) {
// Two Line Message Centering
int lenLine1 = newline - message;
char line1[lenLine1 + 1];
strncpy(line1, message, lenLine1);
line1[lenLine1] = '\0';
const char* line2 = newline + 1;
display.getTextBounds(line1, 0, 0, &x1, &y1, &w, &h); // Use height of one line
int totalTextHeight = h * 2 + 5;
int baselineY1 = STATUS_AREA_Y + (STATUS_AREA_HEIGHT - totalTextHeight) / 2 + h;
if (baselineY1 < STATUS_AREA_Y + h) baselineY1 = STATUS_AREA_Y + h;
drawCenteredText(line1, baselineY1, &FreeSans9pt7b, STATUS_AREA_WIDTH, STATUS_AREA_X);
drawCenteredText(line2, baselineY1 + h + 5, &FreeSans9pt7b, STATUS_AREA_WIDTH, STATUS_AREA_X);
} else {
// Single Line Message Centering
display.getTextBounds(message, 0, 0, &x1, &y1, &w, &h);
int topY = STATUS_AREA_Y + (STATUS_AREA_HEIGHT - h) / 2;
int baselineY = topY - y1;
if (baselineY < STATUS_AREA_Y - y1) baselineY = STATUS_AREA_Y - y1;
if (baselineY > STATUS_AREA_Y + STATUS_AREA_HEIGHT ) baselineY = STATUS_AREA_Y + STATUS_AREA_HEIGHT;
drawCenteredText(message, baselineY, &FreeSans9pt7b, STATUS_AREA_WIDTH, STATUS_AREA_X);
}
} while (display.nextPage());
}
// ===================================================================================
// Clear Display Function (FULL UPDATE)
// ===================================================================================
void clearDisplay() {
Serial.println("Executing clearDisplay() (Full Update)...");
performQuickFullClear(); // Use the helper function
Serial.println("Display cleared (Full Update finished). Screen is blank.");
// Hibernation handled by the loop
}
// ===================================================================================
// Helper Function: Perform Quick Full Clear (No Hibernate)
// ===================================================================================
/**
* @brief Clears the entire display using a full update but DOES NOT hibernate.
* Used internally before showing a partial update after a full update.
*/
void performQuickFullClear() {
display.setFullWindow();
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE);
} while (display.nextPage());
}
// ===================================================================================
// Update QR Display Function (FULL UPDATE)
// ===================================================================================
void updateQrDisplay(const char* textToEncode) {
Serial.printf("Updating QR display (Full Update) for: '%s'\n", textToEncode);
display.setFullWindow();
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE);
drawQrScreen(textToEncode);
} while (display.nextPage());
// Hibernation handled by the loop
}
// ===================================================================================
// Draw QR Screen Function (Called during FULL UPDATE)
// ===================================================================================
void drawQrScreen(const char* textToEncode) {
// (Function remains the same as previous version)
bool qrSuccess = drawQrCode(0, 0, display.width(), display.height(), textToEncode);
if (!qrSuccess) {
Serial.println("QR Code drawing failed. Displaying error message (Full Update).");
display.setFont(&FreeSans9pt7b);
int16_t x1, y1; uint16_t w, h;
const char* errMsg = "QR Generation Failed";
display.getTextBounds(errMsg, 0, 0, &x1, &y1, &w, &h);
int baselineY = (display.height() - h) / 2 - y1;
drawCenteredText(errMsg, baselineY, &FreeSans9pt7b);
} else {
Serial.println("QR Code drawn successfully (Full Update).");
}
}
// ===================================================================================
// Helper Function: Draw Centered Text
// ===================================================================================
void drawCenteredText(const char *text, int baselineY, const GFXfont *font, int targetW /* = -1 */, int targetX /* = 0 */) {
// (Function remains the same as previous version)
if (!font || !text || text[0] == '\0') return;
int16_t x1, y1; uint16_t w, h;
display.setFont(font);
display.setTextColor(GxEPD_BLACK);
display.setTextSize(1);
display.getTextBounds(text, 0, 0, &x1, &y1, &w, &h);
int areaWidth = (targetW <= 0) ? display.width() : targetW;
int areaOriginX = (targetW <= 0) ? 0 : targetX;
int cursorX = areaOriginX + (areaWidth - w) / 2 - x1;
display.setCursor(cursorX, baselineY);
display.print(text);
}
// ===================================================================================
// Draw QR Code Function (Used by FULL UPDATE's drawQrScreen)
// ===================================================================================
bool drawQrCode(int x_target_area, int y_target_area, int w_target_area, int h_target_area, const char *text) {
// (Function remains the same as previous version)
if (text == NULL || text[0] == '\0') { Serial.println("QR Error: No text provided."); return false; }
int inputLength = strlen(text);
if (inputLength > MAX_INPUT_STRING_LENGTH) { Serial.printf("QR Error: Input text too long (%d > %d).\n", inputLength, MAX_INPUT_STRING_LENGTH); return false; }
Serial.printf("Generating QR Code for: '%s' (Length: %d)\n", text, inputLength);
uint32_t bufferSize = qrcode_getBufferSize(FIXED_QR_VERSION);
if (bufferSize == 0) { Serial.printf("QR Error: Could not get buffer size for version %d.\n", FIXED_QR_VERSION); return false; }
const int MAX_STACK_QR_BUFFER = 4096;
if (bufferSize > MAX_STACK_QR_BUFFER) { Serial.printf("QR Error: Calculated buffer size (%d) may be too large for stack.\n", bufferSize); return false; }
uint8_t qrcodeData[bufferSize];
QRCode qrcode;
esp_err_t err = qrcode_initText(&qrcode, qrcodeData, FIXED_QR_VERSION, ECC_LOW, text);
if (err != ESP_OK) { Serial.printf("QR Error: qrcode_initText failed. Error code: %d. Input may be too long for Version %d/ECC_LOW.\n", err, FIXED_QR_VERSION); return false; }
Serial.printf("QR generated successfully: Version=%d, Size=%dx%d modules\n", qrcode.version, qrcode.size, qrcode.size);
int qr_modules_size = qrcode.size;
int module_pixel_size = FIXED_QR_SCALE;
int final_qr_pixel_size = qr_modules_size * module_pixel_size;
if (final_qr_pixel_size > w_target_area || final_qr_pixel_size > h_target_area) { Serial.printf("QR Warning: Scaled QR code data area (%dpx) might be larger than target area (%dx%d). Clipping might occur.\n", final_qr_pixel_size, w_target_area, h_target_area); }
int x_offset = x_target_area + (w_target_area - final_qr_pixel_size) / 2;
int y_offset = y_target_area + (h_target_area - final_qr_pixel_size) / 2;
if (x_offset < x_target_area) x_offset = x_target_area;
if (y_offset < y_target_area) y_offset = y_target_area;
Serial.printf("Drawing QR Code at display offset (%d, %d) with scale %d. Target area: (%d,%d %dx%d)\n", x_offset, y_offset, module_pixel_size, x_target_area, y_target_area, w_target_area, h_target_area);
display.startWrite();
for (int y = 0; y < qr_modules_size; y++) {
for (int x = 0; x < qr_modules_size; x++) {
if (qrcode_getModule(&qrcode, x, y)) {
int moduleX = x_offset + x * module_pixel_size;
int moduleY = y_offset + y * module_pixel_size;
if (moduleX >= 0 && (moduleX + module_pixel_size) <= display.width() && moduleY >= 0 && (moduleY + module_pixel_size) <= display.height()) {
display.fillRect(moduleX, moduleY, module_pixel_size, module_pixel_size, GxEPD_BLACK);
}
}
}
}
display.endWrite();
return true;
}
10/04/2025
Mastering Large Format CNC Machining
Week 8
Weekly Update
This week, we dove into the world of subtractive manufacturing on a larger scale with CNC milling.
The task was to design and fabricate something "large" using a sheet of plywood (typically
500 x 400 mm, with available thicknesses like 12mm or 18mm). We were encouraged to
utilize various 2.5D CAM operations within Fusion 360, such as Pocket, Contour, Bore, Face, Trace,
and 2D Chamfer, using standard wood end mills.
Having zero previous experience in CAM and wanting to create a
lasting memento from this course, I decided to design a sign featuring "JVC" (the initials of our
course) and "2025" (the current year). My goal was to focus on the design and CAM preparation
stages, creating a piece that would serve as a nice reminder of the skills learned.
Modeling & Initial CAM Strategy: Fusion 360
I modeled a simple rectangular plaque in Fusion 360 with the text "JVC" and "2025"
raised from the surface, surrounded by a raised border. The next step was to define the
manufacturing process using Fusion 360's Manufacturing workspace.
My initial plan involved using two primary CAM operations with a single tool:
A 2D Pocket operation to clear out the
background material around the letters and the border, making them stand proud.
A 2D Contour operation to cut the final sign
shape out from the larger plywood stock, cutting through the full material thickness.
I set up these operations selecting a standard 6mm Flat End
Mill from the Fusion 360 tool library.
The complete Fusion 360 design and manufacturing setup, including toolpaths, can be explored via
the link below.
Design & Initial CAM Visualization
Initial CAM Simulation Video
Simulation showing
the initial 2D Pocket and 2D Contour toolpaths.
Initial CAM Parameter Details
Here are the key parameters from the initial Fusion 360 CAM setup:
Setup (Setup3)
Operation Type: Milling
WCS Origin: Stock box point (Top-left corner)
Model: Selected body
Fixture: None selected (Clearances set to 5mm)
Tool (T1)
Description: #1 - Ø6mm flat (Flat end mill from Fusion library)
Diameter: 6 mm
Flute Length: 18 mm
Overall Length: 36 mm
Coolant: Disabled
Feeds & Speeds (Used for initial Pocket & Contour)
After simulating the toolpaths, particularly the 2D Pocket operation, it became
clear that machining the entire background would be very time-consuming (estimated machining time was significant) and generate
a large amount of wood debris.
Considering the available machine time and the desire for a cleaner, quicker process, it was decided
collaboratively with the instructors to revise the approach. Instead
of pocketing, we opted to machine only the outlines of the letters and the border frame using a
shallow engraving pass. This would still provide the desired visual definition but significantly
reduce machining time and material waste. The CAM setup was conceptually adjusted to use a
2D Contour or Trace operation following the lines of the text and
the border at a small depth, instead of the extensive pocketing. The final perimeter cut-out
operation (2D Contour) would remain similar but might use adjusted settings.
Export and Supervised Machining
Based on the revised strategy (outlines only), the final G-code toolpaths were generated. My part of
the hands-on process concluded with exporting these machining program files.
From this point, the instructors took over. They reviewed the
generated G-code for safety and feasibility, uploaded it to the CNC machine controller, secured the
12mm plywood stock (the final thickness used), and set the work coordinate system
(WCS) origin
on the machine. Crucially, they monitored the entire machining
process, ensuring everything ran smoothly and safely, which is standard procedure for
these larger student projects.
Even though we didn't get to run the machine ourselves this time, it was still really helpful. We'd
already had lectures on how to use it, so watching the instructors set everything up and run our
design file gave us some great real-world insight. You could really see how the workflow goes, why
tweaking the settings is so important, and all the safety stuff you need to think about when you're
working with big CNC machines.
Final Result & Reflection
The final sign looks pretty good! It definitely works as a memento using just the outlines.
It's different from what I originally had in mind – I was thinking of something more involved with
fully pocketed letters – but the outlined version is really clean and easy to read. Plus, it was way
faster to make. I think deciding to skip all that pocketing was the right
call in the end.
The finished sign -
outlines only, machined from 12mm plywood.
Looking at the finished sign, I noticed a little burn mark on the '0' and '2'. It's probably
something to do with the feed rate, how sharp the tool was, or maybe even the way the tool moved in
those corners. Even though I didn't actually get to run the machine myself this week, it was still a
really great introduction to CAM. I'm actually pretty happy with how this personalized memento
turned out, and I definitely feel like I've got a good foundation in CAM now!
17/04/2025
Networked Timetables: ESP32, APIs, and Beating the Bus
Week 9
Weekly Update
This week's assignment dove into the world of networking, pushing us
to connect our microcontrollers beyond their immediate circuits. The core tasks were:
Control your circuit over a local wireless network.
Send or receive data from the Internet.
Confession time: I have a complicated relationship with Prague's public transport timetables. Let's
just say my arrival time at school often has more variance than a quantum particle. Could technology
finally help me catch that elusive bus? This week's assignment felt like the perfect opportunity to
try.
My plan: build a small, networked display showing real-time departure information for a specific bus
stop near me. This involved fetching data from an online API and displaying it locally, hitting both
assignment requirements. I chose the LILYGO T-Display board for its
integrated ESP32 and color TFT screen, and the Golemio API for
Prague's public transport data.
The Hardware: LILYGO T-Display
The LILYGO T-Display is a compact development board featuring an ESP32-WROOM-32 module (providing WiFi and Bluetooth), integrated with a
1.14-inch color IPS TFT LCD (ST7789 driver, 135x240 pixels). It also
includes standard peripherals like USB-C (with a CH9102 USB-to-Serial chip on my version), buttons,
and pins for expansion. Its small size and built-in display make it ideal for projects needing both
connectivity and a simple visual output, like this departure board. Power is supplied via USB-C or
an external battery connection.
The Data Source: Golemio Prague Public Transport API
Golemio is Prague's official open data platform,
providing various datasets and APIs related to the city, including public transport schedules,
locations, and real-time updates. Crucially for this project, it's free to use for development and
public projects, though registration is required to obtain an API key.
I specifically used the PID Departure Boards (v2) endpoint. This API allows
fetching upcoming departures from specific stops.
Getting Started with Golemio API:
Sign Up & Get Key: Register at api.golemio.cz/api-keys to get your personal API access token (JWT).
This key is needed for authentication.
Explore the Docs (Swagger UI): Golemio provides excellent interactive API
documentation using Swagger UI (linked above). This interface is key for understanding and
testing the API:
Authorize: First, click the "Authorize" button (often a padlock icon)
and paste your API key into the value field to authenticate your session.
Explore Parameters: Find the /v2/pid/departureboards GET
endpoint.
You can see all the available query parameters like ids (GTFS ID),
aswIds (ASW
Node/Stop ID), cisIds (CIS ID), names (Stop Name),
limit, minutesAfter, skip,
etc. For my project, I focused on using aswIds.
Find Your Stop ID (aswIds): This was the trickiest part.
The API
documentation for aswIds mentions: "First part of the number represents
the whole
node... second part... represents individual stops... Use _ instead of /... A list
of ASW IDs can be found in Prague
Open data."
Follow the "Prague Open data" link.
Find the "Zastávky" (Stops) section and look for "Seznam zastávek (JSON)" (List
of Stops JSON).
Click "Stáhnout" (Download) to get the stops.json file. Warning:
It's a large
file!
This JSON contains all stops, grouped by node ID. Each individual
stop within
a node has an id like "NODE/STOP_NUMBER" (e.g.,
"9/1").
To find the ID for a specific stop (e.g., "Vinořský zámek"), you need to search
this file. A simple Python script helps (see below).
Once you find your stop name, note its node ID and the specific
stop id
(like "9/1"). For the API's aswIds parameter, combine them using an
underscore: NODE_STOPNUMBER (e.g., 9_1). You might
also just use the node
ID if you want departures from all platforms at that node (e.g.,
9). For my
stop "Vinořský zámek", the relevant node ID was 9549, and the
specific
platform I needed was 9549/2, so I used aswIds=9549_2
in the API call.
Try it Out: Back in the Swagger UI, click the "Try it out" button for
the endpoint. Fill in the required parameters (you need at least one ID like
aswIds,
ids, cisIds or names). Set limit
(e.g., 4), minutesAfter (e.g., 120), and your
chosen aswIds.
Execute: Click the "Execute" button.
Examine Results: The UI will show:
A curl command example (useful for command-line testing).
The exact Request URL that was generated based on
your parameters. This is super helpful for constructing the URL in your ESP32
code.
The Server response body (the JSON data) and the
response code (hopefully 200 OK!). Studying this JSON structure is essential for
parsing it later.
Golemio API Swagger Docs - Authorize & Find Endpoint
Clicking "Try it out" and filling parameters (like aswIds)
Executing the request and viewing the generated Request URL and JSON Response
Testing directly in the Swagger UI, especially seeing the generated Request URL and the exact JSON
response, was invaluable for figuring out the correct stop_id, experimenting with
parameters, and
understanding the data structure before writing any ESP32 code.
Python Script to Find Stop IDs from stops.json
To search the large stops.json file downloaded from Prague Open Data, you can use this
simple
Python script. Save it as find_stop_id.py in the same directory as
stops.json.
import json
import sys
def find_stop_info(filename="stops.json", search_name=""):
if not search_name:
print("Please provide a stop name to search for.")
print("Usage: python find_stop_id.py \"Stop Name\"")
return
try:
with open(filename, 'r', encoding='utf-8') as f:
data = json.load(f)
except FileNotFoundError:
print(f"Error: File '{filename}' not found.")
print("Make sure stops.json is in the same directory.")
return
except json.JSONDecodeError:
print(f"Error: Could not decode JSON from '{filename}'.")
return
found_stops = []
search_name_lower = search_name.lower()
# Iterate through stop groups
for group in data.get("stopGroups", []):
group_name = group.get("name", "")
# Check if the group name matches (case-insensitive)
if search_name_lower in group_name.lower():
node_id = group.get("node")
print(f"\nFound matching group: '{group_name}' (Node ID: {node_id})")
# Iterate through individual stops within the group
for stop in group.get("stops", []):
stop_id = stop.get("id") # Format "NODE/STOP_NUM"
platform = stop.get("platform", "N/A")
gtfs_ids = stop.get("gtfsIds", [])
asw_id_api_format = f"{node_id}_{stop_id.split('/')[-1]}" if stop_id and '/' in stop_id else str(node_id)
print(f" - Stop ID: {stop_id} (Platform: {platform})")
print(f" ASW ID for API (Node_StopNum): {asw_id_api_format}")
print(f" GTFS IDs: {gtfs_ids}")
# You could add more details here if needed (lines, etc.)
found_stops.append(group_name) # Mark as found
if not found_stops:
print(f"\nNo stop groups found containing the name '{search_name}'.")
else:
print(f"\nFound {len(found_stops)} matching group(s). Use the 'Node ID' and 'Stop ID' or the combined 'ASW ID for API' format.")
if __name__ == "__main__":
if len(sys.argv) > 1:
stop_name_to_find = sys.argv[1]
find_stop_info(search_name=stop_name_to_find)
else:
# Example usage if no command line argument is given
print("No stop name provided via command line.")
print("Example Usage: python find_stop_id.py \"Vinořský zámek\"")
# find_stop_info(search_name="Vinořský zámek") # Uncomment to run default search
Run it from your terminal like this: python find_stop_id.py "Your Stop Name" (e.g.,
python find_stop_id.py "Vinořský zámek")
The script will print the Node ID and individual Stop IDs (along with the format needed for the
aswIds parameter) for any stops containing the name you searched for.
Found matching group: 'Vinořský zámek' (Node ID: 9549)
- Stop ID: 9549/1 (Platform: A)
ASW ID for API (Node_StopNum): 9549_1
GTFS IDs: ['U9549Z1P', 'U9549Z1']
- Stop ID: 9549/2 (Platform: B)
ASW ID for API (Node_StopNum): 9549_2
GTFS IDs: ['U9549Z2P', 'U9549Z2']
Project Setup: PlatformIO and Libraries
For this project, I used the PlatformIO IDE
extension within Visual Studio Code. PlatformIO simplifies managing development environments,
libraries, and board configurations, especially for complex projects involving specific hardware
like the T-Display.
Libraries Used
The project relies on several key libraries, managed via PlatformIO's library manager:
WiFi.h: (Built-in ESP32 framework) For connecting to the local WiFi network.
WiFiClientSecure.h: (Built-in) For establishing secure HTTPS connections and
handling TLS.
ArduinoJson.h (by Benoit Blanchon): For efficiently parsing the JSON data returned
by the Golemio API.
TFT_eSPI.h (by Bodmer): A powerful library for driving various TFT displays,
including the ST7789 on the LILYGO board. Crucially, this library needs specific
configuration for the target board.
time.h: (Built-in) For handling time conversions (Epoch time, local time).
PlatformIO Configuration (platformio.ini)
The heart of the PlatformIO project setup is the platformio.ini file. Instead of
manually editing
library configuration files (like User_Setup.h for TFT_eSPI), PlatformIO
allows us to define
these settings directly using build flags. This keeps the
configuration specific to the project and prevents issues when updating libraries.
Here's the platformio.ini configuration used for the T-Display:
PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:esp32dev]
platform = espressif32 ; Use the Espressif 32 platform
board = esp32dev ; Base board type (specific pins defined in flags)
framework = arduino ; Use the Arduino framework
monitor_speed = 115200 ; Serial monitor baud rate
build_flags = ; Flags passed to the compiler
; --- TFT_eSPI Configuration Flags ---
-DUSER_SETUP_LOADED=1 ; Tell TFT_eSPI we are loading setup here
-DST7789_DRIVER=1 ; Specify the ST7789 display driver IC
-DTFT_WIDTH=135 ; Define display width in pixels
-DTFT_HEIGHT=240 ; Define display height in pixels
-DCGRAM_OFFSET=1 ; Specific offset needed for some ST7789 variants
; --- Pin Definitions for LILYGO T-Display ---
-DTFT_MOSI=19 ; SPI MOSI pin
-DTFT_SCLK=18 ; SPI Clock pin
-DTFT_CS=5 ; SPI Chip Select pin
-DTFT_DC=16 ; Data/Command pin
-DTFT_RST=23 ; Reset pin (use 23 for T-Display, -1 if not connected)
-DTFT_BL=4 ; Backlight control pin
-DTFT_BACKLIGHT_ON=HIGH ; Logic level to turn backlight on
; --- Font Loading Flags for TFT_eSPI ---
-DLOAD_GLCD=1 ; Load default GLCD font
-DLOAD_FONT2=1 ; Load Font 2
-DLOAD_FONT4=1 ; Load Font 4
-DLOAD_FONT6=1 ; ...and so on
-DLOAD_FONT7=1
-DLOAD_FONT8=1
-DLOAD_GFXFF=1 ; Load Adafruit GFX Free Fonts
-DSMOOTH_FONT=1 ; Enable anti-aliased fonts
; --- SPI Speed Settings ---
-DSPI_FREQUENCY=40000000 ; Set SPI frequency (40MHz is often stable)
-DSPI_READ_FREQUENCY=6000000 ; SPI read frequency (if applicable)
lib_deps = ; Library dependencies
bodmer/TFT_eSPI@^2.5.43 ; TFT_eSPI library from PlatformIO registry
bblanchon/ArduinoJson@^7.4.1 ; ArduinoJson library
Key points about these flags:
-DUSER_SETUP_LOADED=1 tells TFT_eSPI to ignore its internal setup
files and use
the definitions provided here.
-DST7789_DRIVER, -DTFT_WIDTH, -DTFT_HEIGHT configure the
specific display type.
-DTFT_MOSI, -DTFT_SCLK, etc., map the library's pin functions to the
actual ESP32 pins connected to the display on the T-Display board. Getting these right
is critical!
-DLOAD_FONT... flags enable specific fonts within the library, making them
available for use in the code.
The lib_deps section automatically downloads and includes the specified versions of
TFT_eSPI
and ArduinoJson when building the project.
HTTPS Connection and Certificate Handling
Fetching data from the Golemio API requires an internet connection (via WiFi) and making an HTTPS request. HTTPS encrypts the data exchanged, but requires
verifying the server's identity using SSL/TLS certificates.
Securing the Connection: SSL Certificates
To ensure the ESP32 talks to the real api.golemio.cz, the WiFiClientSecure
library needs to
verify the server's certificate against a trusted Root Certificate Authority (CA) certificate
embedded in the code.
My preferred workflow for getting the Root CA certificate was:
Navigate to https://api.golemio.cz in a web browser.
Inspect the certificate details via the padlock icon.
Browser indicating a secure connection
Navigating the Certificate Viewer
Identify and export the top-level Root CA (e.g., "GTS Root R4") in Base64 PEM
format.
Selecting the Root CA and exporting
Copy the exported certificate text (including BEGIN/END lines).
Paste the result into the api_cfg.root_ca variable in the code.
This Root CA certificate verifies the server's identity and is typically valid for few months.
Code Implementation and Logic
The Arduino code (available below) orchestrates the entire process. Here's a
breakdown of the logic:
High-level program flowchart
Full Code
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h> // For HTTPS
#include <ArduinoJson.h> // For parsing API response (v7+)
#include <TFT_eSPI.h> // For the LILYGO T-Display
#include <SPI.h> // Required by TFT_eSPI
#include <time.h> // For time functions
//------------------------------------------------------------------------------
// Configuration Structs - Define network, API, timing, and display settings
//------------------------------------------------------------------------------
struct NetworkConfig
{
const char *ssid; // WiFi network name
const char *password; // WiFi password
unsigned long wifi_timeout_ms; // How long to wait for WiFi connection
};
struct ApiConfig
{
const char *server; // API server hostname
const int port; // API server port (443 for HTTPS)
const char *root_ca; // Root CA certificate for verifying the server
const char *api_key; // Your Golemio API key
const char *id_type; // Type of stop ID used (e.g., "aswIds")
const char *stop_id; // The specific stop ID to query
int limit; // Max number of departures to fetch
const char *order; // Order of results (e.g., "timetable")
const char *filter_param; // Filter parameter (e.g., "none")
const char *skip_param; // Skip parameter (e.g., "canceled")
int minutes_after; // How many minutes ahead to look for departures
const char *air_condition; // Include air conditioning info (string "true" or "false")
};
struct TimingConfig
{
unsigned long refresh_interval_ms; // How often to refresh data (milliseconds)
unsigned long https_timeout_ms; // Timeout for the HTTPS request
};
struct TftDisplayConfig
{
uint8_t rotation; // Display rotation (0-3)
uint8_t default_text_size; // Default font size for TFT_eSPI
uint16_t bg_color; // Background color
uint16_t default_text_color; // Default text color
uint16_t header_bg_color; // Header background color
uint16_t header_text_color; // Header text color
int header_y; // Y position of the header bar
int start_y; // Y position where departure data starts
int line_height; // Vertical space for each departure line
// Column X positions for data alignment
int col_x_route;
int col_x_depart;
int col_x_info_start; // Destination starts here
int col_x_status; // Delay/cancel status starts here
// Backlight PWM settings
const int bl_pin; // TFT Backlight control pin (often predefined)
const int bl_pwm_channel; // LEDC PWM channel for backlight
const int bl_pwm_freq; // PWM frequency
const int bl_pwm_resolution; // PWM resolution (e.g., 8-bit for 0-255)
const uint8_t default_brightness; // Default backlight brightness (0-255)
};
//------------------------------------------------------------------------------
// INSTANTIATE CONFIGURATIONS ( *** MODIFY THESE FOR YOUR SETUP *** )
//------------------------------------------------------------------------------
const NetworkConfig network_cfg = {
.ssid = "YOUR_WIFI_SSID", // <<< CHANGE THIS
.password = "YOUR_WIFI_PASSWORD", // <<< CHANGE THIS
.wifi_timeout_ms = 15000};
const ApiConfig api_cfg = {
.server = "api.golemio.cz",
.port = 443,
.root_ca = // DigiCert Global Root CA (Valid for Golemio API as of early 2025)
"-----BEGIN CERTIFICATE-----\n"
"MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD\n"
"VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n"
//more lines here
"9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8\n"
"p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD\n"
"-----END CERTIFICATE-----\n",
.api_key = "YOUR_GOLEMIO_API_KEY", // <<< CHANGE THIS
.id_type = "aswIds", // Use ASW IDs for stops
.stop_id = "9549_2", // Specific stop ID (e.g., a bus stop) <<< CHANGE IF NEEDED
.limit = 4, // Show top 4 departures
.order = "timetable", // Order by scheduled time
.filter_param = "none", // No specific filter
.skip_param = "canceled", // Skip canceled departures in the API response
.minutes_after = 120, // Look for departures up to 120 mins from now
.air_condition = "false" // Don't specifically request AC info
};
const TimingConfig timing_cfg = {
.refresh_interval_ms = 60000, // Refresh every 60 seconds
.https_timeout_ms = 15000 // HTTPS request timeout
};
const TftDisplayConfig display_cfg = {
.rotation = 1, // Landscape mode (adjust if needed)
.default_text_size = 2, // Standard text size
.bg_color = TFT_BLACK,
.default_text_color = TFT_WHITE,
.header_bg_color = TFT_NAVY,
.header_text_color = TFT_WHITE,
.header_y = 2, // Header Y position
.start_y = 25, // Data starts below header
.line_height = 24, // Space per line
// Column X positions (adjust for your screen/font)
.col_x_route = 3, // Route number column
.col_x_depart = 38, // Departure time/minutes column
.col_x_info_start = 85, // Destination column
.col_x_status = 195, // Delay/Status column
// Backlight settings (TFT_BL might be defined in TFT_eSPI user setup)
.bl_pin = TFT_BL,
.bl_pwm_channel = 0,
.bl_pwm_freq = 5000,
.bl_pwm_resolution = 8, // 8-bit = 0-255 brightness
.default_brightness = 150 // Default brightness
};
//------------------------------------------------------------------------------
// Global Variables & Objects
//------------------------------------------------------------------------------
TFT_eSPI tft = TFT_eSPI(); // Instantiate the TFT display driver
WiFiClientSecure client; // Secure client for HTTPS communication
const size_t API_BUFFER_SIZE = 4096; // Max size for API response buffer
char apiResponseBuffer[API_BUFFER_SIZE]; // Buffer to store the API response
unsigned long lastRefreshTime = 0; // Tracks when the data was last refreshed
time_t currentEpochTime = 0; // Stores the current time (obtained from server)
//------------------------------------------------------------------------------
// Function Prototypes
//------------------------------------------------------------------------------
void connectWiFi(const NetworkConfig &config);
bool performApiRequest(const ApiConfig &config, const TimingConfig &timings, char *responseBuffer, size_t bufferSize);
String buildApiUrl(const ApiConfig &config);
bool parseAndDisplayData(const char *responseData);
void renderTftDisplay(JsonArray departures);
void displayTftMessage(const char *message, uint16_t textColor = display_cfg.default_text_color);
void displayTftError(const char *message);
bool fetchAndDisplayDepartures();
String removePrahaPrefix(const char *headsign);
String transliterateCzech(const String &input);
bool parseHttpDate(const char *dateStr, time_t &epoch);
bool parseScheduledTime(const char *timeStr, time_t &epoch);
void setTimezone();
void setupBacklight(const TftDisplayConfig &config);
void setBacklight(const TftDisplayConfig &config, uint8_t brightness);
//------------------------------------------------------------------------------
// Set Timezone Function (Crucial for correct time display)
//------------------------------------------------------------------------------
void setTimezone()
{
// POSIX string for Prague/Central European Time (CET/CEST)
// CET-1CEST: Std time CET is UTC+1, DST time CEST is UTC+2.
// ,M3.5.0: DST starts last Sunday of March (default 2am)
// ,M10.5.0: DST ends last Sunday of October (default 2am)
const char *pragueTZ = "CET-1CEST,M3.5.0,M10.5.0";
Serial.printf("Setting Timezone to %s\n", pragueTZ);
setenv("TZ", pragueTZ, 1); // Set the TZ environment variable
tzset(); // Apply the timezone setting
}
//------------------------------------------------------------------------------
// Setup Function
//------------------------------------------------------------------------------
void setup()
{
Serial.begin(115200);
while (!Serial)
delay(100); // Wait for serial connection
Serial.println(F("\n\n--- TFT Departure Board ---"));
setTimezone(); // Configure the timezone early
setupBacklight(display_cfg); // Initialize backlight PWM
setBacklight(display_cfg, display_cfg.default_brightness); // Set initial brightness
tft.init(); // Initialize the TFT display
tft.setRotation(display_cfg.rotation);
tft.fillScreen(display_cfg.bg_color);
tft.setTextSize(display_cfg.default_text_size);
tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color);
tft.setTextWrap(false); // Prevent text wrapping
Serial.println(F("TFT Display initialized."));
displayTftMessage("Connecting WiFi...");
connectWiFi(network_cfg); // Connect to the network
bool initialFetchSuccess = false;
if (WiFi.status() == WL_CONNECTED)
{
displayTftMessage("Fetching data...");
initialFetchSuccess = fetchAndDisplayDepartures(); // Perform first data fetch
}
if (!initialFetchSuccess && WiFi.status() != WL_CONNECTED)
{
displayTftError("WiFi Failed!");
}
else if (!initialFetchSuccess)
{
Serial.println(F("Initial data fetch failed."));
displayTftError("Fetch Fail"); // Show error if fetch failed after WiFi connect
}
else
{
Serial.println(F("Initial data fetched and displayed."));
}
Serial.println(F("Setup complete."));
}
//------------------------------------------------------------------------------
// Main Loop
//------------------------------------------------------------------------------
void loop()
{
unsigned long currentTime = millis();
// Start the refresh timer only after setup is clearly finished
if (lastRefreshTime == 0 && currentTime > 5000)
{ // Avoid immediate refresh right at boot
lastRefreshTime = currentTime;
Serial.println("Starting refresh timer.");
}
// Perform periodic refresh if the interval has passed
if (lastRefreshTime != 0 && (currentTime - lastRefreshTime >= timing_cfg.refresh_interval_ms))
{
Serial.println(F("Refresh interval elapsed. Performing refresh."));
if (!fetchAndDisplayDepartures())
{
Serial.println(F("Periodic refresh failed."));
// Error message is already displayed on TFT by fetchAndDisplayDepartures
}
else
{
Serial.println(F("Refresh successful."));
}
// lastRefreshTime is updated inside fetchAndDisplayDepartures on success
}
delay(100); // Small delay to prevent busy-waiting and yield to other tasks
}
//------------------------------------------------------------------------------
// Backlight Functions
//------------------------------------------------------------------------------
void setupBacklight(const TftDisplayConfig &config)
{
if (config.bl_pin >= 0) // Check if a valid backlight pin is configured
{
Serial.printf("Setting up PWM for BL Pin %d\n", config.bl_pin);
ledcSetup(config.bl_pwm_channel, config.bl_pwm_freq, config.bl_pwm_resolution);
ledcAttachPin(config.bl_pin, config.bl_pwm_channel);
}
else
{
Serial.println("Backlight Pin not configured or invalid.");
}
}
void setBacklight(const TftDisplayConfig &config, uint8_t brightness)
{
if (config.bl_pin >= 0)
{
ledcWrite(config.bl_pwm_channel, brightness); // Set brightness using PWM
}
}
//------------------------------------------------------------------------------
// Helper Function: Parse HTTP Date Header (GMT to Epoch)
// Essential for getting accurate current time from the server response
//------------------------------------------------------------------------------
bool parseHttpDate(const char *dateStr, time_t &epoch)
{
const char *dateValueStart = strchr(dateStr, ':'); // Find first colon
if (!dateValueStart)
return false;
dateValueStart++; // Move past the colon
while (*dateValueStart == ' ')
dateValueStart++; // Skip leading spaces
struct tm t;
memset(&t, 0, sizeof(tm)); // Clear struct
// Try parsing standard RFC 1123 format (e.g., "Date: Wed, 21 Oct 2015 07:28:00 GMT")
if (strptime(dateValueStart, "%a, %d %b %Y %H:%M:%S GMT", &t) != NULL)
{
t.tm_isdst = 0; // Tell mktime this is GMT and not subject to local DST rules
// Temporarily set system TZ to GMT to correctly convert the parsed GMT time to epoch
char *originalTZ = getenv("TZ");
setenv("TZ", "GMT0", 1);
tzset();
epoch = mktime(&t); // Convert struct tm (in GMT) to time_t (seconds since epoch)
// Restore original timezone
setenv("TZ", originalTZ ? originalTZ : "", 1);
tzset();
if (epoch == (time_t)-1)
{
Serial.println("WARN: mktime failed for HTTP Date header.");
return false;
}
return true;
}
else
{
Serial.println("WARN: strptime failed to parse HTTP Date header.");
return false;
}
}
//------------------------------------------------------------------------------
// Helper Function: Parse Scheduled Time (ISO 8601-like with offset to Epoch)
// Parses departure time strings like "2024-04-17T15:30:00+02:00"
//------------------------------------------------------------------------------
bool parseScheduledTime(const char *timeStr, time_t &epoch)
{
struct tm t;
int year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0;
int tz_hour = 0, tz_minute = 0;
char tz_sign = '+';
memset(&t, 0, sizeof(tm)); // Clear struct
// Parse the ISO 8601-like string
if (sscanf(timeStr, "%d-%d-%dT%d:%d:%d%c%d:%d",
&year, &month, &day, &hour, &minute, &second, &tz_sign, &tz_hour, &tz_minute) >= 8)
{
t.tm_year = year - 1900; // Years since 1900
t.tm_mon = month - 1; // Months since January (0-11)
t.tm_mday = day;
t.tm_hour = hour;
t.tm_min = minute;
t.tm_sec = second;
t.tm_isdst = -1; // Let system determine if DST is active for this date/time *if* using local mktime
// Calculate the timezone offset in seconds from the parsed string
long timezone_offset_seconds = (long)tz_hour * 3600L + (long)tz_minute * 60L;
if (tz_sign == '-')
{
timezone_offset_seconds = -timezone_offset_seconds;
}
// Convert the parsed date/time parts to Epoch (seconds since 1970-01-01 00:00:00 UTC)
// Crucially, treat the parsed time as if it were GMT/UTC first, then adjust by the offset.
char *originalTZ = getenv("TZ");
setenv("TZ", "GMT0", 1); // Temporarily set TZ to GMT for mktime calculation
tzset();
time_t time_in_gmt = mktime(&t); // Get epoch assuming the Y/M/D H:M:S were GMT
setenv("TZ", originalTZ ? originalTZ : "", 1); // Restore original timezone
tzset();
if (time_in_gmt == (time_t)-1)
{
Serial.println("WARN: mktime failed for scheduled time.");
return false;
}
// Adjust the GMT epoch time by the timezone offset to get the true UTC epoch time
epoch = time_in_gmt - timezone_offset_seconds;
return true;
}
else
{
Serial.println("WARN: sscanf failed to parse scheduled time string.");
return false;
}
}
//------------------------------------------------------------------------------
// Helper Function: Remove "Praha," Prefix from headsigns for cleaner display
//------------------------------------------------------------------------------
String removePrahaPrefix(const char *headsign)
{
if (headsign == nullptr)
return "";
String s = String(headsign);
if (s.startsWith("Praha,"))
{
int startIndex = 6; // Length of "Praha,"
// Skip the space after the comma if it exists
if (startIndex < s.length() && s.charAt(startIndex) == ' ')
{
startIndex++;
}
return s.substring(startIndex);
}
return s; // Return original string if prefix not found
}
//------------------------------------------------------------------------------
// Helper Function: Transliterate common Czech characters to basic ASCII
// Needed because TFT_eSPI built-in fonts don't support all UTF-8 chars
//------------------------------------------------------------------------------
String transliterateCzech(const String &input)
{
String output = "";
output.reserve(input.length()); // Pre-allocate memory for efficiency
for (unsigned int i = 0; i < input.length();)
{
uint8_t c1 = input.charAt(i);
uint8_t c2 = (i + 1 < input.length()) ? input.charAt(i + 1) : 0;
char replacement = '?'; // Default replacement for unknown UTF-8 sequences
int charsToSkip = 1; // How many bytes the UTF-8 sequence occupies
if (c1 < 128)
{ // Standard ASCII character
replacement = (char)c1;
}
else if (c1 == 0xC3)
{ // Common accented vowels (Latin Extended-A)
charsToSkip = 2;
switch (c2)
{
case 0xA1:
replacement = 'a';
break; // á
case 0xAD:
replacement = 'i';
break; // í
case 0xB3:
replacement = 'o';
break; // ó
case 0xBA:
replacement = 'u';
break; // ú
case 0xBD:
replacement = 'y';
break; // ý
case 0x81:
replacement = 'A';
break; // Á
case 0x8D:
replacement = 'I';
break; // Í
case 0x93:
replacement = 'O';
break; // Ó
case 0x9A:
replacement = 'U';
break; // Ú
case 0x9D:
replacement = 'Y';
break; // Ý
default:
charsToSkip = 1;
replacement = '?'; // Unknown C3 sequence
}
}
else if (c1 == 0xC4)
{ // Č,Ď,Ě (Latin Extended-A)
charsToSkip = 2;
switch (c2)
{
case 0x8D:
replacement = 'c';
break; // č
case 0x8F:
replacement = 'd';
break; // ď
case 0x9B:
replacement = 'e';
break; // ě
case 0x8C:
replacement = 'C';
break; // Č
case 0x8E:
replacement = 'D';
break; // Ď
case 0x9A:
replacement = 'E';
break; // Ě
default:
charsToSkip = 1;
replacement = '?'; // Unknown C4 sequence
}
}
else if (c1 == 0xC5)
{ // Ň,Ř,Š,Ť,Ů,Ž (Latin Extended-A)
charsToSkip = 2;
switch (c2)
{
case 0x88:
replacement = 'n';
break; // ň
case 0x99:
replacement = 'r';
break; // ř
case 0xA1:
replacement = 's';
break; // š
case 0xA5:
replacement = 't';
break; // ť
case 0xAF:
replacement = 'u';
break; // ů
case 0xBE:
replacement = 'z';
break; // ž
case 0x87:
replacement = 'N';
break; // Ň
case 0x98:
replacement = 'R';
break; // Ř
case 0xA0:
replacement = 'S';
break; // Š
case 0xA4:
replacement = 'T';
break; // Ť
case 0xAE:
replacement = 'U';
break; // Ů
case 0xBD:
replacement = 'Z';
break; // Ž
default:
charsToSkip = 1;
replacement = '?'; // Unknown C5 sequence
}
}
else
{ // Skip other multi-byte UTF-8 sequences or invalid bytes
if ((c1 & 0xE0) == 0xC0)
charsToSkip = 2; // 2-byte sequence start
else if ((c1 & 0xF0) == 0xE0)
charsToSkip = 3; // 3-byte sequence start
else if ((c1 & 0xF8) == 0xF0)
charsToSkip = 4; // 4-byte sequence start
else
charsToSkip = 1; // Treat as invalid single byte
replacement = '?';
}
output += replacement;
i += charsToSkip;
}
return output;
}
//------------------------------------------------------------------------------
// Combined Fetch, Parse, and Display Function
//------------------------------------------------------------------------------
bool fetchAndDisplayDepartures()
{
Serial.println(F("\nFetching departures..."));
// Check WiFi connection and attempt reconnect if necessary
if (WiFi.status() != WL_CONNECTED)
{
Serial.println(F("WiFi disconnected. Reconnecting..."));
connectWiFi(network_cfg);
if (WiFi.status() != WL_CONNECTED)
{
displayTftError("No WiFi"); // Show error on display
return false; // Failed to connect
}
}
// Perform the HTTPS request
if (!performApiRequest(api_cfg, timing_cfg, apiResponseBuffer, API_BUFFER_SIZE))
{
// Error message already displayed on TFT by performApiRequest
return false; // Request failed
}
// Parse the JSON response and update the display
if (!parseAndDisplayData(apiResponseBuffer))
{
// Error message already displayed on TFT by parseAndDisplayData
return false; // Parsing or display failed
}
// If everything succeeded, update the last refresh time
lastRefreshTime = millis();
Serial.println(F("Fetch/Display OK."));
return true;
}
//------------------------------------------------------------------------------
// WiFi Connection Function
//------------------------------------------------------------------------------
void connectWiFi(const NetworkConfig &config)
{
Serial.print(F("Connecting to "));
Serial.println(config.ssid);
WiFi.mode(WIFI_STA); // Set WiFi mode to Station
WiFi.begin(config.ssid, config.password);
unsigned long startTime = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startTime < config.wifi_timeout_ms)
{
Serial.print(".");
delay(500);
}
if (WiFi.status() == WL_CONNECTED)
{
Serial.println(F("\nWiFi Connected"));
Serial.print(F("IP Address: "));
Serial.println(WiFi.localIP());
}
else
{
Serial.println(F("\nWiFi Connection Failed!"));
WiFi.disconnect(true); // Disconnect completely on failure
delay(100);
}
}
//------------------------------------------------------------------------------
// URL Builder Function - Constructs the API request path and query parameters
//------------------------------------------------------------------------------
String buildApiUrl(const ApiConfig &config)
{
String url = "/v2/pid/departureboards/?";
url += config.id_type; // e.g., aswIds
url += "=";
url += config.stop_id; // e.g., 9549_2
url += "&limit=";
url += String(config.limit);
url += "&order=";
url += config.order;
url += "&filter=";
url += config.filter_param;
url += "&skip=";
url += config.skip_param;
url += "&minutesAfter=";
url += String(config.minutes_after);
url += "&airCondition=";
url += config.air_condition;
Serial.print(F("Constructed API Path: "));
Serial.println(url);
return url;
}
//------------------------------------------------------------------------------
// API Request Function (Handles HTTPS connection and data retrieval)
//------------------------------------------------------------------------------
bool performApiRequest(const ApiConfig &config, const TimingConfig &timings, char *responseBuffer, size_t bufferSize)
{
currentEpochTime = 0; // Reset current time before each request
Serial.println(F("Setting up HTTPS client..."));
client.setCACert(config.root_ca); // Provide the Root CA cert for server verification
client.setTimeout(timings.https_timeout_ms); // Set connection timeout
Serial.print(F("Connecting to API server: "));
Serial.print(config.server);
Serial.print(":");
Serial.println(config.port);
if (!client.connect(config.server, config.port))
{
Serial.println(F("Connection failed!"));
displayTftError("Connect Fail");
return false;
}
Serial.println(F("Connection successful."));
String urlPath = buildApiUrl(config); // Get the specific API path
Serial.println(F("Sending HTTPS GET request..."));
// Construct the HTTP GET request
client.print(F("GET "));
client.print(urlPath);
client.println(F(" HTTP/1.1"));
client.print(F("Host: "));
client.println(config.server);
client.println(F("Connection: close")); // Indicate server should close connection after response
client.print(F("x-access-token: ")); // Send the API key header
client.println(config.api_key);
client.println(); // End of headers (blank line)
if (client.println() == 0)
{ // Check if sending failed
Serial.println(F("Failed to send request headers."));
client.stop();
displayTftError("Send Fail");
return false;
}
Serial.println(F("Waiting for response headers..."));
// Wait for the server to start sending data
unsigned long headerTimeoutStart = millis();
while (client.connected() && !client.available())
{
if (millis() - headerTimeoutStart > 5000)
{ // 5 second timeout for headers
Serial.println("Header Timeout!");
client.stop();
displayTftError("Hdr Timeout");
return false;
}
delay(10); // Small delay
}
// Read and process HTTP headers
bool httpStatusOK = false;
String httpStatusCode = "";
String dateHeaderValue = "";
bool dateHeaderFound = false;
while (client.available())
{
String line = client.readStringUntil('\n');
line.trim(); // Remove leading/trailing whitespace and \r
// Serial.println(line); // Uncomment to debug headers
if (line.startsWith("HTTP/1."))
{ // Check status code line
int firstSpace = line.indexOf(' ');
int secondSpace = line.indexOf(' ', firstSpace + 1);
if (secondSpace > firstSpace && firstSpace > 0)
{
httpStatusCode = line.substring(firstSpace + 1, secondSpace);
if (httpStatusCode == "200")
{
httpStatusOK = true;
Serial.print("HTTP Status: 200 OK");
}
else
{
Serial.print("HTTP Status: ");
Serial.print(httpStatusCode);
}
Serial.println();
}
}
else if (line.startsWith("date:"))
{ // Find the Date header (case-insensitive)
dateHeaderValue = line;
dateHeaderFound = true;
Serial.print("Found Date header: ");
Serial.println(dateHeaderValue);
// Try to parse the date header to get current server time (GMT)
if (!parseHttpDate(dateHeaderValue.c_str(), currentEpochTime))
{
Serial.println("Failed to parse Date header.");
currentEpochTime = 0; // Reset if parsing fails
}
else
{
Serial.print("Parsed current Epoch time (GMT): ");
Serial.println(currentEpochTime);
}
}
else if (line.length() == 0)
{ // Empty line signifies end of headers
Serial.println("End of headers reached.");
break;
}
}
if (!httpStatusOK)
{
Serial.print(F("HTTP Error: "));
Serial.println(httpStatusCode);
client.stop();
String errorMsg = "HTTP Err " + (httpStatusCode.isEmpty() ? "?" : httpStatusCode);
displayTftError(errorMsg.c_str());
return false;
}
if (!dateHeaderFound)
{
Serial.println("WARN: Date header missing from response! Time calculations may be inaccurate.");
currentEpochTime = 0; // Ensure time is marked as invalid if header missing
}
Serial.println(F("Reading response body..."));
memset(responseBuffer, 0, bufferSize); // Clear the buffer
size_t bytesRead = 0;
unsigned long bodyTimeoutStart = millis();
// Read the response body content
while (client.connected() || client.available())
{
if (client.available())
{
size_t len = client.readBytes(&responseBuffer[bytesRead], bufferSize - 1 - bytesRead);
if (len > 0)
{
bytesRead += len;
bodyTimeoutStart = millis(); // Reset timeout timer on receiving data
if (bytesRead >= bufferSize - 1)
{
Serial.println("WARN: Response buffer full!");
break; // Stop reading if buffer is full
}
}
}
else if (millis() - bodyTimeoutStart > (timings.https_timeout_ms - 5000))
{ // Body timeout
Serial.println("Body Read Timeout!");
break;
}
}
responseBuffer[bytesRead] = '\0'; // Null-terminate the received data
client.stop(); // Close the connection
Serial.printf("Read %d bytes of response body.\n", bytesRead);
if (bytesRead == 0 && httpStatusOK)
{
Serial.println(F("Received OK status but no response body."));
displayTftMessage("No data in response"); // Display a message instead of error
return false; // Treat as failure for this specific application
}
if (currentEpochTime == 0)
{
Serial.println("WARN: Could not determine current time from server. Departure calculations will be inaccurate.");
// Display might show scheduled times but not 'minutes until'
}
return true; // Request and reading completed successfully
}
//------------------------------------------------------------------------------
// JSON Parsing and Display Trigger
//------------------------------------------------------------------------------
bool parseAndDisplayData(const char *responseData)
{
// Check for potential chunked encoding size prefix (e.g., "1a2f\r\n{...")
const char *jsonStart = responseData;
if (strlen(responseData) > 5 && responseData[0] != '{' && responseData[0] != '[')
{
const char *chunkEnd = strstr(responseData, "\r\n");
if (chunkEnd && (chunkEnd[2] == '{' || chunkEnd[2] == '['))
{
jsonStart = chunkEnd + 2; // Start parsing after the chunk size and CRLF
Serial.println("Skipped potential chunk size line.");
}
else
{
// If not chunked, try finding the first '{' as a fallback
const char *firstBrace = strchr(responseData, '{');
if (firstBrace)
{
Serial.println("WARN: Response doesn't start with '{' or '['. Attempting parse from first '{'.");
jsonStart = firstBrace;
}
else
{
Serial.println("ERROR: Cannot find start of JSON ('{' or '[') in the response.");
displayTftError("Bad Resp");
return false; // Cannot parse
}
}
}
Serial.println(F("Parsing JSON response..."));
JsonDocument doc; // Use dynamic allocation (can adjust size if needed)
DeserializationError error = deserializeJson(doc, jsonStart);
if (error)
{
Serial.print(F("JSON parsing failed: "));
Serial.println(error.f_str());
displayTftError("JSON Error");
return false; // Parsing failed
}
Serial.println(F("JSON parsed successfully."));
// Extract the "departures" array. If it doesn't exist or isn't an array,
// departures.isNull() will be true or departures.size() will be 0.
JsonArrayConst departures = doc["departures"].as<JsonArrayConst>();
// Update the TFT display with the parsed data (or lack thereof)
renderTftDisplay(departures);
// Return true signifies that parsing and the attempt to display were completed
return true;
}
//------------------------------------------------------------------------------
// Render Departures on TFT Display
//------------------------------------------------------------------------------
void renderTftDisplay(JsonArrayConst departures) // Use JsonArrayConst for read-only access
{
// --- Draw Header ---
int headerTextY = display_cfg.header_y + 3; // Y position for text inside header bar
int headerHeight = tft.fontHeight(1) + 6; // Calculate header bar height
tft.fillRect(0, display_cfg.header_y, tft.width(), headerHeight, display_cfg.header_bg_color);
tft.setTextColor(display_cfg.header_text_color, display_cfg.header_bg_color);
// Draw header text using text datum for alignment
tft.setTextDatum(TL_DATUM); // Top Left
tft.drawString("BUS", 3, headerTextY);
tft.setTextDatum(TC_DATUM); // Top Center
tft.drawString("DEPART", tft.width() / 2, headerTextY);
tft.setTextDatum(TR_DATUM); // Top Right
tft.drawString("INFO", tft.width() - 3, headerTextY);
// --- Reset text settings for data area ---
tft.setTextDatum(TL_DATUM); // Reset datum to Top Left for data rows
tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color);
// --- Clear Data Area ---
int dataAreaY = display_cfg.header_y + headerHeight;
// Calculate clear height: bottom edge minus data start Y minus bottom time display area
int dataClearHeight = tft.height() - dataAreaY - (tft.fontHeight(1) + 4);
if (dataClearHeight < 0)
dataClearHeight = 0; // Ensure non-negative height
tft.fillRect(0, dataAreaY, tft.width(), dataClearHeight, display_cfg.bg_color);
// --- Draw Departures ---
int yPos = display_cfg.start_y; // Starting Y for the first data line
if (departures.isNull() || departures.size() == 0)
{
Serial.println("Render: No departures to display.");
tft.setCursor(10, yPos); // Position cursor
tft.print("No departures found.");
}
else
{
// Serial.printf("Render: Processing %d departures.\n", departures.size()); // Less verbose logging
int count = 0;
for (JsonVariantConst departure_v : departures)
{
if (count >= api_cfg.limit)
break; // Stop if we've reached the display limit
if (!departure_v.is<JsonObjectConst>())
continue; // Skip if not a valid JSON object
JsonObjectConst departure = departure_v.as<JsonObjectConst>();
// --- Extract Data ---
const char *route_cstr = departure["route"]["short_name"] | "N/A"; // Bus/Tram number
const char *time_sched_cstr = departure["departure_timestamp"]["scheduled"] | ""; // Scheduled ISO time string
const char *headsign_raw = departure["trip"]["headsign"] | ""; // Raw destination headsign
bool cancelled = departure["trip"]["is_canceled"] | false; // Is the trip cancelled?
// --- Time Calculations ---
time_t scheduledEpoch = 0;
time_t actualDepartureEpoch = 0;
long minutesUntil = -1; // -1: Unknown, -2: Gone, 0: <1 min, >0: Minutes until
String departureTimeStr = " ???"; // String to display (e.g., " 15", " <1", " Gone")
int delaySeconds = 0;
bool delayAvailable = false;
// Only calculate 'minutes until' if we have a valid current time from the server
if (currentEpochTime > 0 && time_sched_cstr[0] != '\0')
{
if (parseScheduledTime(time_sched_cstr, scheduledEpoch))
{
actualDepartureEpoch = scheduledEpoch; // Start with scheduled time
// Check for delay information
JsonVariantConst delayInfo = departure["delay"];
if (delayInfo.is<JsonObjectConst>() && (delayInfo["is_available"] | false))
{
delayAvailable = true;
delaySeconds = (delayInfo["minutes"] | 0) * 60 + (delayInfo["seconds"] | 0);
actualDepartureEpoch += delaySeconds; // Add delay to get actual departure time
}
// Calculate difference in seconds
long diffSeconds = actualDepartureEpoch - currentEpochTime;
if (cancelled)
{ // Override if cancelled
minutesUntil = -1; // Not relevant
departureTimeStr = " XXX"; // Indicate cancelled
}
else if (diffSeconds < -30)
{ // Allow for slight clock skew, considered gone if > 30s past
minutesUntil = -2;
departureTimeStr = " Gone";
}
else if (diffSeconds < 60)
{ // Departing within the next minute
minutesUntil = 0;
departureTimeStr = " <1";
}
else
{ // Calculate minutes until departure
minutesUntil = (diffSeconds + 30) / 60; // Round to nearest minute
departureTimeStr = " ";
if (minutesUntil < 10)
departureTimeStr += " "; // Pad for alignment
departureTimeStr += String(minutesUntil);
}
}
else
{
// Failed to parse scheduled time, maybe show raw time? For now, leave as ???
}
}
else if (time_sched_cstr[0] != '\0' && parseScheduledTime(time_sched_cstr, scheduledEpoch))
{
// If we don't have current time, at least show the scheduled time HH:MM
struct tm timeinfo_sched;
localtime_r(&scheduledEpoch, &timeinfo_sched); // Convert epoch to local time struct
char buf[6];
strftime(buf, sizeof(buf), "%H:%M", &timeinfo_sched);
departureTimeStr = String(buf);
}
// --- Format Headsign ---
String headsign_processed = removePrahaPrefix(headsign_raw);
headsign_processed = transliterateCzech(headsign_processed); // Convert Czech chars
// --- Truncate Headsign if too long for the display area ---
int maxWidth = display_cfg.col_x_status - display_cfg.col_x_info_start - 4; // Available width
int16_t text_w = tft.textWidth(headsign_processed);
if (text_w > maxWidth)
{
String truncated = headsign_processed;
while (truncated.length() > 0 && tft.textWidth(truncated + "..") > maxWidth)
{
truncated.remove(truncated.length() - 1); // Remove last char
}
headsign_processed = (truncated.length() > 0) ? truncated + ".." : ".."; // Add ellipsis
}
// --- Determine Status String and Color ---
String statusStr = "";
uint16_t statusColor = display_cfg.default_text_color;
if (cancelled)
{
statusStr = " X"; // Simple 'X' for cancelled
statusColor = TFT_RED;
departureTimeStr = " XXX"; // Overwrite time too
}
else if (delayAvailable && delaySeconds > 59)
{ // Show delay only if >= 1 minute
int rounded_delay_min = (delaySeconds + 30) / 60; // Round delay to nearest minute
if (rounded_delay_min > 0)
{
statusStr = "+";
statusStr += String(rounded_delay_min);
// Color code delay: Orange for small, Red for large
statusColor = (rounded_delay_min <= 3) ? TFT_ORANGE : TFT_RED;
}
}
// --- Set Route Color (Example for specific lines) ---
uint16_t routeColor = TFT_WHITE; // Default
if (strcmp(route_cstr, "375") == 0)
routeColor = TFT_YELLOW;
else if (strcmp(route_cstr, "302") == 0)
routeColor = tft.color565(255, 165, 0); // Orange
else if (strcmp(route_cstr, "378") == 0)
routeColor = TFT_MAGENTA;
// Add more else if clauses for other routes if desired
// --- Draw Data Row ---
// Route Number (Left Aligned in its column)
tft.setTextColor(routeColor, display_cfg.bg_color);
tft.setCursor(display_cfg.col_x_route, yPos);
char routeBuffer[6]; // Buffer to format route number (e.g., left-align)
snprintf(routeBuffer, sizeof(routeBuffer), "%-4s", route_cstr); // Left align in 4 spaces
tft.print(routeBuffer);
// Departure Time/Minutes (Left Aligned in its column)
tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color);
tft.setCursor(display_cfg.col_x_depart + 1, yPos); // Add slight padding
tft.print(departureTimeStr);
// Destination/Headsign (Left Aligned in its column)
tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color);
tft.setCursor(display_cfg.col_x_info_start, yPos);
tft.print(headsign_processed);
// Status/Delay (Right Aligned in its column)
tft.setTextColor(statusColor, display_cfg.bg_color);
int statusColWidth = tft.width() - display_cfg.col_x_status - 2; // Width of status column
text_w = tft.textWidth(statusStr); // Width of the actual status text
int statusX = display_cfg.col_x_status + statusColWidth - text_w; // Calculate X for right alignment
if (statusX < display_cfg.col_x_status)
statusX = display_cfg.col_x_status; // Prevent going left of column start
tft.setCursor(statusX, yPos);
tft.print(statusStr);
// --- Move to next line ---
yPos += display_cfg.line_height;
count++;
} // End for loop
} // End else (departures available)
// --- Draw Current Time at Bottom RIGHT ---
// Clear previous time
int timeTextHeight = tft.fontHeight(1); // Using default font size 1 for time
int timeBoxWidth = tft.textWidth("00:00") + 6; // Estimated width
int timeBoxHeight = timeTextHeight + 2;
int timeBoxX = tft.width() - timeBoxWidth - 1; // X position of the box
int timeBoxY = tft.height() - timeBoxHeight - 1; // Y position of the box
tft.fillRect(timeBoxX, timeBoxY, timeBoxWidth, timeBoxHeight, display_cfg.bg_color);
char timeStr[6] = "--:--"; // Default if time unknown
if (currentEpochTime > 0)
{
struct tm timeinfo_now;
// Convert the *server-provided* epoch time to our *local* timezone struct
localtime_r(¤tEpochTime, &timeinfo_now);
strftime(timeStr, sizeof(timeStr), "%H:%M", &timeinfo_now); // Format as HH:MM
}
tft.setTextDatum(BR_DATUM); // Bottom Right Datum for alignment
tft.setTextColor(TFT_CYAN, display_cfg.bg_color); // Use a different color for time
tft.drawString(timeStr, tft.width() - 3, tft.height() - 2); // Draw time at bottom right
tft.setTextDatum(TL_DATUM); // Reset datum
tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color); // Reset color
} // End of renderTftDisplay
//------------------------------------------------------------------------------
// Display General Message/Error on TFT
//------------------------------------------------------------------------------
void displayTftMessage(const char *message, uint16_t textColor)
{
tft.fillScreen(display_cfg.bg_color); // Clear screen
tft.setTextColor(textColor, display_cfg.bg_color);
tft.setTextDatum(MC_DATUM); // Middle Center Datum
tft.drawString(message, tft.width() / 2, tft.height() / 2); // Draw centered text
tft.setTextDatum(TL_DATUM); // Reset datum
Serial.printf("TFT_MSG: %s\n", message); // Log message to serial
}
// Helper function specifically for errors (uses red text)
void displayTftError(const char *message)
{
displayTftMessage(message, TFT_RED);
}
//------------------------------------------------------------------------------
// Placeholder for Power Saving (Not implemented in this version)
//------------------------------------------------------------------------------
// void goToSleep()
// {
// Serial.println(F("Entering deep sleep (not implemented)..."));
// setBacklight(display_cfg, 0); // Turn off backlight
// // esp_deep_sleep_start(); // Actual sleep command
// }
Result
The completed Departure Board in action
Success! The LILYGO T-Display now connects to WiFi, securely fetches departure data from the Golemio
API every minute, parses the JSON, calculates the time until departure (using the server's time for
accuracy), handles delays and cancellations, and displays the next few buses for my stop. The
display shows the route number, minutes until departure (or scheduled time if sync fails), the
destination, and any delay status.
Will this actually make me less late? Probably not (hehe). But it was a fantastic exercise in
integrating hardware with web APIs, handling HTTPS, parsing data, managing time zones, and
configuring a specific board using PlatformIO.
Plus, it looks pretty cool.
14/05/2025
PixelTag: E-Paper Badge & Flutter Controller App
Week 10
Weekly Update
This week's task, focusing on interface and application programming, formed a major part of
completing my final project: the PixelTag. The goal was to develop the
mobile application needed to control the custom e-paper badge, allowing wireless updates of personal
information and QR codes via Bluetooth Low Energy (BLE).
The specific assignment goals were:
Developing an application/interface for a device.
Implementing communication between the interface and the device.
PixelTag Badge: Firmware Development (ESP32)
The core of the badge is an ESP32 microcontroller connected to a small e-paper display (LILYGO®
T5-2.13 inch). The firmware, written using the Arduino framework for ESP32, handles several key
tasks:
BLE Communication: Establishes a secure BLE connection (using bonding) with a
central device (the mobile app). It exposes characteristics for writing commands/data and
reading back current information (Name, Email/Title, Phone, QR URL) and battery level.
Display Management: Uses the GxEPD2 library to control the e-paper display. It supports two main
modes: displaying personal information (3 lines) or displaying a QR code. Full updates are used
for clarity when switching modes or data.
Data Persistence: Stores the last received personal info, QR code URL, and
display mode in Non-Volatile Storage (NVS) using the Preferences library. This ensures the badge
remembers its state even after a reboot or deep sleep.
QR Code Generation: Utilizes the ESP-IDF QR Code Generator library (wrapped for Arduino) to create QR
codes from the provided URL data.
User Interaction: A physical button (using the OneButton library) allows
cycling through the display modes (Info -> QR -> Blank -> Info) without needing the app.
Power Management: Implements deep sleep to conserve battery, waking up on a
button press or automatically after a timeout when disconnected from BLE.
The firmware structure involves setting up BLE services/characteristics, handling callbacks for
connection events and data writes/reads, managing display updates, and implementing the state
machine for different display modes and button interactions. The text layout on the badge itself is
quite basic for now, focusing on functionality over aesthetics – definitely an area for future
improvement.
Badge Displaying Personal Info
Badge Displaying QR Code
ESP32 Firmware Code Snippet (Conceptual Overview)
Below is a simplified overview of the structure. The full code handles BLE setup, security,
display drawing, NVS storage, button input, and power management.
// Include Libraries (GxEPD2, BLE, Preferences, OneButton, QRCode)
#include <GxEPD2_BW.h>
#include <BLEDevice.h>
#include <Preferences.h>
#include <OneButton.h>
#include <qrcode.h>
#include "GxEPD2_display_selection_new_style.h" // Display configuration
// --- Defines ---
// UUIDs, Pins, Max Lengths, Display Modes enum...
// --- Globals ---
// BLE pointers, display object, current data strings, state variables...
DisplayMode currentMode = INFO;
String personalInfo = "";
String qrCodeData = "";
bool deviceConnected = false;
OneButton button(BUTTON_PIN, true, true);
Preferences preferences;
// --- BLE Callbacks ---
// MyServerCallbacks (onConnect/onDisconnect)
// DataCharacteristicCallbacks (onWrite: parse commands "command:clear", "display:info", "display:qr", "data:personal:...", "data:qr:...")
// ReadCharacteristicCallbacks (onRead: provide name, email, phone, qr url, battery)
// --- Setup ---
void setup() {
// Serial init, NVS init (load saved data/mode)
// Display init
// Button init (attachClick)
// setupBLE() -> Initialize BLE server, services, characteristics, security
// Configure deep sleep wakeup (button)
// Initial display update based on loaded data/mode
// Start advertising if needed
}
// --- Loop ---
void loop() {
button.tick(); // Handle button presses
if (deviceConnected) {
// Process BLE commands/data received in callbacks
// Handle mode change requests (BLE or button)
// Update display if data/mode changed (updateDisplay())
// Send battery notifications periodically
} else {
// Handle mode change requests from button
// Update display if mode changed
// Check for deep sleep timeout
// Stop advertising and enter deep sleep if timeout reached
}
delay(10); // Small delay to avoid busy loop
}
// --- Core Functions ---
void setupBLE(); // Creates services, characteristics (Write, Read Name/Email/Phone/QR, Battery Notify)
void updateDisplay(); // Full e-paper refresh based on currentMode
void drawInfoScreen(); // Draws centered multi-line text
void drawQrScreen(); // Generates and draws QR code
void performFullClear(); // Clears the display
void handleButtonClick(); // Cycles through modes (INFO->QR->BLANK->INFO), handles cooldown
uint8_t readBatteryLevel(); // Reads ADC pin for battery %
void sendBatteryNotification(); // Sends battery level over BLE if connected
// ... other helper functions ...
Note: This is a simplified representation. The actual code involves detailed error handling,
buffer management, and specific BLE configurations. The full source code will be available
in the repository linked below.
PixelTag Controller: Mobile Application (Flutter)
To interact with the badge, I developed a mobile application using Flutter. Flutter
is Google's UI toolkit for building beautiful, natively compiled applications for mobile (Android &
iOS), web, and desktop from a single codebase, using the Dart programming language. This allowed for
rapid development and the potential for cross-platform deployment.
While Flutter allows for building iOS apps from the same codebase, I was unable to test or compile
for iOS due to the requirement of a macOS machine for the necessary build tools and deployment.
Therefore, the current testing and available build are focused on Android.
Key features of the Flutter app include:
BLE Scanning & Connection: Scans for nearby BLE devices using packages like flutter_blue_plus
(requires location permission on Android). Allows filtering by device name (set in settings).
Connects securely to the selected PixelTag using bonding.
Device Status: Once connected, displays the device name, connection status
(Bonded), and the badge's battery level (read from the standard BLE Battery Service).
Data Refresh & Disconnect: Buttons to manually refresh the data read from the
badge (pulling current Name, Email, Phone, QR URL) and to disconnect from the device.
Personal Information Update: Input fields for Name, Email/Title, and Phone
Number (optional). Character limits are enforced based on settings. A "Send Personal Info"
button transmits the data to the badge via the write characteristic.
QR Code Update: An input field for a URL. A "Send QR Code" button generates the
appropriate command and sends the URL to the badge.
Settings: A separate screen allows users to configure:
Dark Mode toggle.
Unpairing the currently bonded device.
Device Name Filter for scanning.
Maximum character length limits for each field (Name, Email, Phone, URL) - these should
match the limits expected by the firmware.
The application provides a straightforward interface for managing the badge's content, leveraging
Flutter's Material widget set to create a familiar look and feel. It's currently a first
version prototype, so the UI/UX design isn't polished, and there might be some
undiscovered bugs or areas for improvement in robustness and user feedback.
App: Location Permission (for BLE Scan)
App: Scanning for Devices
App: Scanning for Devices
App: Connected & Data Input
App: Settings
System Interaction
The system works as follows:
The user opens the Flutter mobile app and scans for devices.
The app lists nearby PixelTags (optionally filtered).
The user selects a device, initiating a secure BLE connection and bonding process.
Once connected, the app can read the current badge data (Name, Email, etc.) and battery level
using specific BLE characteristics.
The user inputs new Personal Info or a QR URL in the app and presses the corresponding "Send"
button.
The app formats the data into a specific string format (e.g., "data:personal:Name\nEmail\nPhone"
or "data:qr:URL") and writes it to the badge's main data characteristic.
The ESP32 firmware receives the string, parses it, updates its internal data variables, saves
the data to NVS, sets the requested display mode, and triggers a full refresh of the e-paper
display.
The physical button on the badge can also be used to cycle between the last sent Info screen, QR
screen, and a blank screen, even when disconnected from the app.
Challenges & Future Work
Developing this system involved tackling BLE complexities within the Flutter ecosystem, especially
secure bonding and managing different characteristics across platforms (even if only Android was
tested). Debugging communication between the app and the ESP32 required careful logging on both
ends. Power optimization for the badge is ongoing, relying currently on deep sleep and infrequent
display updates.
Future improvements could include:
Implementing partial updates for the e-paper display for faster, less visually disruptive
changes.
Improving the text layout and font choices on the badge display.
Adding more sophisticated error handling and user feedback in the Flutter app.
Testing and distributing an iOS build (requires access to macOS).
Exploring image display capabilities on the badge.
This week successfully integrated the hardware badge with a functional mobile interface built using
Flutter. The PixelTag system now allows for easy wireless updates of the e-paper display,
demonstrating a practical application of embedded systems design, BLE communication, and
cross-platform mobile app development (with current focus on Android). While still a prototype with
room for refinement, it fulfills the core requirements of the final
project.