
---
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 didn’t 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 it’s 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 it’s 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 there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

- A **330–470 Ω** 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 doesn’t faint at boot.

I route the strip **DIN → DOUT** through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check **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 same‑size buttons, a `/schedule` table, a drag‑and‑drop `/calendar`, a `/status` page that updates once a second, and `/history` charts for CO₂/temperature/humidity with white backgrounds so they’re 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. 

There’s 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 doesn’t feel like a stoplight:

- **< 500 ppm**: Cyan — outdoor‑like fresh air.  
- **500–799**: Green — good.  
- **800–1199**: Yellow — getting stale.  
- **1200–1499**: Orange — poor; you’ll feel it.  
- **1500–1999**: 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 doesn’t ping‑pong 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 don’t 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 it’s 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 it’s like this:* LEDs are zen monks—disturb 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 it’s 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 it’s 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 doesn’t freeze on the last pose if you stop mid-frame.

*Why it’s 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)  
- `500–799`: **Green**  
- `800–1199`: **Yellow**  
- `1200–1499`: **Orange**  
- `1500–1999`: **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 it’s like this:* EMA + hysteresis prevents “800↔801 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 it’s 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 14–17 → `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 it’s 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** (0–180 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 it’s like this:* minimal routes are easier to maintain and don’t 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 there’s 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 it’s 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.

That’s 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. That’s why it doesn’t randomly flash during web requests.
- **Atomic schedule saves.** The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.

> No, you don’t *need* the sensor. If it’s 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.

### What’s 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 **5–60 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 app’s 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 it’s 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:

- ~100–200 bytes per row (including overhead).
- 1 sample/minute → ~1,440 rows/day → **~150–300 KB/day** → **55–110 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., **30–60 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 it’s 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.  
- **Heat‑shrink + 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 shouldn’t shout.  
- **Safe Mode default.** People first. Demos are opt‑in.  
- **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 >}}
