mysite/content/posts/inet_logo.md

539 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: "INET Logo That Breathes — From CAD to LEDs to a Calm Little Server"
date: 2025-10-17
tags: ["raspberrypi","ws2812","scd41","iot","flask","fabrication","soldering"]
cover:
image: /images/inet/inet_animation_collage.jpg
alt: "Six looks of the INET LED logo"
caption: "Design → print → solder → code → glow."
draft: false
---
> I didnt add a warning sign to the room. I taught the **logo** to breathe. ;-D
The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it's not good at is **ventilating** itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!
So I crafted the INET logo and gave it a 9-5 job: be a **polite barometer**. If the air is fresh, it glows cool and calm. If its getting stale, it warms up. No blinking warnings, no sound effects — just mood.
This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I'll try to include as much detail as I can.
---
## 1) Sketch → CAD → Print
![Fusion design + logotype](/images/inet/inet_logo_fusion_merged.png)
I started the way all sensible hardware projects start: with **overconfidence** and a sketch. The INET logotype looks simple from the front but its a small maze inside. I modeled pockets for each letter in Fusion: a tall **I**, the curving **N**, three stacked bars for **E**, and the long arm of **T**. Each pocket got:
- **Mounting bosses** for the front diffusers (M2 screws),
- **Cable holes** between chambers (rounded to avoid cutting silicone wire), and
- A small **wiring bay** for the controller and power.
I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at **1-2 mm** thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears.
The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.
The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group.
I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.
---
## 2) The Look I Wanted
![Animations collage](/images/inet/inet_animation_collage.jpg)
I wasn't building a stage light. The goal was **calm**: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the **CO₂ monitor** in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I'm happy with it and want to let it be there doing it's job.
---
## 3) The Tidy Mess Behind the Panel
![Hardware collage](/images/inet/inet_hardware_collage.jpg)
Inside the box theres a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 34 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:
- A **330470 Ω** series resistor on the *data* line right at the first pixel. It damps ringing and prevents weird flickers.
- A **1000 µF** electrolytic across **5V/GND** at the strip start. It handles inrush so the first LED doesnt faint at boot.
I route the strip **DIN → DOUT** through the chambers and use short silicone jumpers at the bends. Every joint gets heatshrink and a tiny dab of hot glue as strain relief. I continuitycheck **before** power and bring brightness up slowly on a bench supply.
The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won't be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD
---
## 4) A Web Panel that Stays Out of the Way
![UI collage](/images/inet/inet_ui_collage.jpg)
The web UI is quiet on purpose: a single navbar, a `/control` page with big samesize buttons, a `/schedule` table, a draganddrop `/calendar`, a `/status` page that updates once a second, and `/history` charts for CO₂/temperature/humidity with white backgrounds so theyre legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.
Theres also a **Safe Mode** switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it's not funny for anyone to fidn this out when looking at your creation. So... yea, I took precautions not to see people getting seizures looking at my creation. :-)
---
## 5) The Color Language for Air
The `co2_monitor` animation maps CO₂ ppm → color and adds slow motion so it doesnt feel like a stoplight:
- **< 500 ppm**: Cyan outdoorlike fresh air.
- **500799**: Green good.
- **8001199**: Yellow getting stale.
- **12001499**: Orange poor; youll feel it.
- **15001999**: Red ventilate now.
- **≥ 2000**: Purple the logo is politely yelling.
I blend the readings with an **exponential moving average** and use **hysteresis** around thresholds so it doesnt pingpong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow **breathing brightness**. The result feels alive but never flashy.
By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it's daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it's color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.
---
## 6) The Code — a guided tour (no giant blob, promise)
This whole project runs from a single Python file. Think of it like a tiny orchestra:
one thread conducts the **LEDs**, one listens politely to the **CO₂ sensor**, one keeps time as the **scheduler**, and a small **web server** hands you the baton when you want to improvise.
Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).
---
### 6.1 Configuration (the knobs)
Let's define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you dont edit code to change pin numbers.
```python
LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
SCHEDULE_PATH="schedule.json"
DB_PATH="sensor.db"
SAFE_MODE=True # hide flashy animations by default
```
*Why its like this:* simple, explicit defaults less why is it dark debugging.
---
### 6.2 Strip setup + frame-diff (no flicker magic)
Now initialize `rpi_ws281x.PixelStrip` and put a **shadow frame buffer** in front of it.
```python
frame = [(0,0,0)] * LED_COUNT
_dirty = False
def _set_pixel(i, r, g, b):
# only touch hardware if the value actually changed
if frame[i] != (r,g,b):
strip.setPixelColorRGB(i, r, g, b)
frame[i] = (r,g,b)
global _dirty; _dirty = True
def flush():
if _dirty: strip.show(); _dirty=False
```
*Why its like this:* LEDs are zen monksdisturb them only when you must. The frame-diff prevents those mysterious one frame flash moments that happen when you call `show()` with identical data.
---
### 6.3 A tiny color wheel helper
Classic rainbow helper used by a few animations:
```python
def wheel(pos):
# 0..255 → (r,g,b)
...
```
*Why its like this:* I'll use it again and again; it keeps color math out of the animations.
---
### 6.4 State & orchestration
Let's hold a registry of animations, a stop flag, and the currently playing thread.
```python
animations = {}
stop_event = threading.Event()
current_thread = None
def register(name, safe=True):
def wrap(fn):
animations[name] = {"fn": fn, "safe": safe}
return fn
return wrap
def run_animation(fn):
# cooperative hand-off: stop current, start next
if current_thread and current_thread.is_alive():
stop_event.set(); current_thread.join()
stop_event.clear()
t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
t.start(); globals()["current_thread"] = t
```
*Why its like this:* adding a new animation is a one-liner `@register("name")`. Clean exits prevent torn frames and half-drawn comets.
---
### 6.5 Animations (the mood)
Each animation is a loop that checks `stop_event.is_set()` and draws frames with `_set_pixel(...)` + `flush()`.
- **`redpulse`** calm breathing in red; great default.
- **`colorwave`** slow rainbow that reads clearly behind diffusers.
- **`comet`** a single head with a fading tail orbiting the logo.
They all end with `finally: clear_strip()` so the panel doesnt freeze on the last pose if you stop mid-frame.
*Why its like this:* cooperative loops + frame-diff = smooth, interruption-safe effects.
---
### 6.6 CO₂ color language (with smoothing + hysteresis)
The star of the show. Now map ppm to color **bands** and keep transitions human-pleasant.
- `< 500`: **Cyan** (fresh)
- `500799`: **Green**
- `8001199`: **Yellow**
- `12001499`: **Orange**
- `15001999`: **Red**
- `≥ 2000`: **Purple**
```python
BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
EMA_ALPHA=0.15; HYST=35
# Every tick:
ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
band = hysteresis_aware_band(ema, last_band, HYST)
target = BANDS[band].color
current = lerp(current, target, 0.10) # gentle hue easing
level = breathe(0.25 Hz, low=10, high=220)
draw all pixels = current * level
```
*Why its like this:* EMA + hysteresis prevents 800801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.
---
### 6.7 Optional SCD41 sensor thread (the polite listener)
If the SCD41 is present, a **dedicated thread** owns I²C and updates a global `co2_data` dict every few seconds. It also logs to a tiny SQLite database.
```python
def _start_sensor():
try:
scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
except: return # sensor optional
def loop():
while True:
time.sleep(5)
if scd4x.data_ready:
co2_data.update({...})
db.insert(timestamp, co2, temperature, humidity)
threading.Thread(target=loop, daemon=True).start()
```
*Why its like this:* one thread owns the bus no read contention. The controller keeps working even without a sensor.
---
### 6.8 Scheduler + 90-minute override (humans win, then reset)
A background thread asks, every few seconds, **which** mode should be running:
1. If the user chose something recently (override), respect it for `TTL = 90 min` (or the duration set in the UI).
2. Otherwise, apply the **default schedule** (Wednesday 1417 `slow`, else `redpulse`).
3. Save/load the schedule atomically to `schedule.json`.
```python
def scheduler_pick():
if now < override_until: return override_mode
for row in load_schedule():
if matches(now, row): return row["mode"]
return "redpulse"
```
*Why its like this:* you can play DJ for a meeting, and the room quietly returns to its routine afterward.
---
### 6.9 The web panel (tiny Flask, big buttons)
Three pages, no fuss:
- `/status` live JSON (`/status_data`) every second current mode + CO₂/Temp/Humidity.
- `/control` grid of same-size buttons (respects **Safe Mode**), optional **duration** (0180 min) with validation.
- `/schedule` simple table editor; **Add Row** and **Save**; writes atomically.
```python
@app.post("/set")
def set_mode():
mode = request.form["mode"]
minutes = clamp( int(form["duration"]), 0, 180 )
set_override(mode, minutes)
run_animation(animations[mode]["fn"])
return redirect("/control")
```
*Why its like this:* minimal routes are easier to maintain and dont compete with the art piece.
---
### 6.10 Boot choreography
At startup I:
1. Try the **sensor thread** (if hardware is there).
2. Start the **scheduler thread**.
3. Immediately play **redpulse** (so theres a friendly glow right away).
4. Start Flask; on shutdown I **clear the strip**.
```python
if __name__ == "__main__":
_start_sensor()
threading.Thread(target=scheduler_loop, daemon=True).start()
run_animation(redpulse)
app.run(host="0.0.0.0", port=5000)
```
*Why its like this:* the room sees something alive instantly; the scheduler will swap in the real choice within a few seconds.
---
### TL;DR (but keep the vibes)
- **Frame-diff** = no flashes.
- **EMA + hysteresis** = smooth, truthful color.
- **Breathing** = presence, not signage.
- **Threads per thing** (LEDs / sensor / scheduler) = no fights.
- **Atomic files** = no corrupted schedules.
- **Safe Mode** by default = people > pixels.
Thats it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.
### Why this shape of code?
- **One thread per hardware thing.** The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides *what* to run and when. No bus fights.
- **Frame diff.** I only call `strip.show()` when a pixel actually changes. Thats why it doesnt randomly flash during web requests.
- **Atomic schedule saves.** The code writes to a temporary file and replaces the old one so a midsave power cut cant corrupt the schedule.
> No, you dont *need* the sensor. If its not connected, the app still runs; `co2_monitor` just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don't you agree?
---
## 7) Sensor Database — what it stores, where it lives, and how far it goes
This project logs room conditions to a **tiny SQLite database** so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.
### Whats stored
A single table called `readings`:
```sql
CREATE TABLE IF NOT EXISTS readings (
timestamp TEXT PRIMARY KEY, -- "YYYY-MM-DD HH:MM:SS"
co2 INTEGER, -- ppm
temperature REAL, -- °C
humidity REAL -- %
);
```
- One row per sample (typically every **560 seconds**; slow it down to reduce writes).
- `timestamp` is the primary key → easy “latest” queries and natural time ordering.
### Where the file lives
- Path is configurable via env var `DB_PATH` (see your systemd unit).
- Default: `sensor.db` in the apps working directory (e.g. `/home/pi/inet-led/sensor.db`).
Check size:
```bash
ls -lh /home/pi/inet-led/sensor.db
```
### How writes happen (and why its safe)
- The **sensor thread** owns I²C and inserts a row only when the sensor reports `data_ready`.
- Inserts are short and journaling protects against power loss.
- For friendlier read/write concurrency, enable WAL once at startup:
```sql
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
```
### Typical size & retention
Back-of-envelope:
- ~100200 bytes per row (including overhead).
- 1 sample/minute → ~1,440 rows/day → **~150300 KB/day** → **55110 MB/year**.
- If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
Prune old data (keep last 90 days):
```sql
DELETE FROM readings
WHERE timestamp < date('now','-90 day');
```
(Optionally run `VACUUM;` after large deletes to reclaim file space.)
### Useful queries
**Latest reading:**
```sql
SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
```
**Range for charts (last 7 days):**
```sql
SELECT * FROM readings
WHERE timestamp >= datetime('now','-7 day')
ORDER BY timestamp;
```
**Downsample to hourly averages (30 days):**
```sql
SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
FROM readings
WHERE timestamp >= datetime('now','-30 day')
GROUP BY hour
ORDER BY hour;
```
**Export to CSV (example via sqlite3 CLI):**
```bash
sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
.headers on
.mode csv
.output /home/pi/inet-led/export_readings.csv
SELECT * FROM readings ORDER BY timestamp;
.output stdout
SQL
```
### SD card friendliness
- Choose a sensible interval (e.g., **3060 s**).
- Optionally buffer in memory and write every N samples.
- Prefer WAL mode; periodically prune & vacuum.
- If you care about card wear, place the DB on USB/SSD.
### Backups & restore
**Nightly backup (simple):**
```bash
sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
```
**Restore:**
1. `sudo systemctl stop inet-led`
2. Copy backup file back to `/home/pi/inet-led/sensor.db`
3. `sudo systemctl start inet-led`
### Security & permissions
```bash
chown pi:pi /home/pi/inet-led/sensor.db
chmod 600 /home/pi/inet-led/sensor.db
```
Keep the app directory non-world-readable.
### Is SQLite the right choice?
**Yes, for a single Pi** logging every few seconds and rendering local charts:
- ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
- ⚠️ One writer at a time (I only have the sensor thread writing, so its fine).
- ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
### TL;DR
SQLite is perfect here: **simple, robust, easy to back up**. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.
---
## 8) Run at Boot (systemd)
I wrote this simple systemd to `/etc/systemd/system/inet-led.service` so that it runs the script when RPi boots up:
```ini
[Unit]
Description=INET LED Panel
After=network.target
[Service]
ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
WorkingDirectory=/home/pi/inet-led
Restart=always
User=pi
Environment=LED_COUNT=78
Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
Environment=DB_PATH=/home/pi/inet-led/sensor.db
[Install]
WantedBy=multi-user.target
```
Then you can run:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now inet-led
journalctl -u inet-led -f
```
to control the systemd service.
---
## 9) Soldering Notes (a love letter to hot glue)
- **Tin first, solder second.** Tiny pads like tiny puddles. Fast in/out, no lifted pads.
- **Stagger joints** so nothing stacks under the diffuser.
- **Heatshrink + a dot of hot glue** = happier future you.
- **Continuity before power.** Multimeter first, electrons last.
- If a pad *does* lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable... So... yea.
---
## 10) Why these design choices?
- **Calm motion, not flashy.** Meeting rooms breed fatigue; the light should be a helper, not a distraction.
- **Cyan→Purple language.** Easy to learn, visible at a glance, meaningful without numbers.
- **White charts, dark UI.** Data should be legible on a projector; buttons shouldnt shout.
- **Safe Mode default.** People first. Demos are optin.
- **PLA body, PLA diffuser.** Fast and easy rapid prototyping, easy and fast printing.
---
## 11) What I learned
- A logo is a better messenger than a dashboard.
- Everyone knows what **orange** means without a legend.
- If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
- Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
---
## 12) Final notes
I did this project as a fun distraction and "not work" as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I'm so so thankful of my advisor for this cool idea, and all the support. It's not the first time I've been gifted such toys, and as I have recieved other things after that, I know it's not the last.
There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let's hope that doesn't turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*
The whole project took around 8 days, 4 of which were part of a long weekend. I did do some "not work" as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don't know how the group feels/thinks about the logo, but I love it! I hope it won't die after I leave, but even if so, so be it. I had my fun with it. :)
---
{{< sigdl >}}