mysite/public-clearnet/posts/inet_logo/index.html

160 lines
No EOL
63 KiB
HTML
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.

<!doctype html><html lang=en dir=auto><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1,shrink-to-fit=no"><meta name=robots content="noindex, nofollow"><title>INET Logo That Breathes — From CAD to LEDs to a Calm Little Server | AlipourIm journeys</title>
<meta name=keywords content="raspberrypi,ws2812,scd41,iot,flask,fabrication,soldering"><meta name=description content="
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&rsquo;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!"><meta name=author content="Iman Alipour"><link rel=canonical href=https://blog.alipour.eu/posts/inet_logo/><link crossorigin=anonymous href=/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css integrity="sha256-MbFQ2QkYbEjW279lOswkiZRfqawsj4zT/oRI6J5A/M8=" rel="preload stylesheet" as=style><link rel=icon href=https://blog.alipour.eu/favicon.ico><link rel=icon type=image/png sizes=16x16 href=https://blog.alipour.eu/favicon-16x16.png><link rel=icon type=image/png sizes=32x32 href=https://blog.alipour.eu/favicon-32x32.png><link rel=apple-touch-icon href=https://blog.alipour.eu/apple-touch-icon.png><link rel=mask-icon href=https://blog.alipour.eu/safari-pinned-tab.svg><meta name=theme-color content="#2e2e33"><meta name=msapplication-TileColor content="#2e2e33"><link rel=alternate hreflang=en href=https://blog.alipour.eu/posts/inet_logo/><noscript><style>#theme-toggle,.top-link{display:none}</style><style>@media(prefers-color-scheme:dark){:root{--theme:rgb(29, 30, 32);--entry:rgb(46, 46, 51);--primary:rgb(218, 218, 219);--secondary:rgb(155, 156, 157);--tertiary:rgb(65, 66, 68);--content:rgb(196, 196, 197);--code-block-bg:rgb(46, 46, 51);--code-bg:rgb(55, 56, 62);--border:rgb(51, 51, 51)}.list{background:var(--theme)}.list:not(.dark)::-webkit-scrollbar-track{background:0 0}.list:not(.dark)::-webkit-scrollbar-thumb{border-color:var(--theme)}}</style></noscript><script>(function(){window.goatcounter={endpoint:"/count"};const e=document.createElement("script");e.async=!0,e.src="/js/count.js",document.head.appendChild(e)})()</script><script>(function(){const s="",t=new Intl.NumberFormat,o="visit",i="visits";function a(e){document.readyState==="complete"||document.readyState==="interactive"?setTimeout(e,0):document.addEventListener("DOMContentLoaded",e)}function r(){try{return window.goatcounter&&window.goatcounter.get_data?window.goatcounter.get_data().p:location.pathname}catch{return location.pathname}}async function e(e,t){try{const o=new URLSearchParams({t:String(Date.now())});t&&o.set("start",t);const a=`${s}/counter/${encodeURIComponent(e)}.json?${o}`,n=await fetch(a,{credentials:"omit"});if(n.status===404)return 0;if(!n.ok)throw 0;const r=await n.json(),i=Number(String(r.count).replace(/,/g,""));return Number.isFinite(i)?i:0}catch{return null}}const n=e=>e===null?"—":`${t.format(e)} ${e===1?o:i}`;a(async function(){const s=document.querySelector(".post-single");if(s){const t=s.querySelector(".post-header .post-meta, .post-header .entry-meta, .post-meta"),o=await e(r());if(t){let e=t.querySelector(".post-views");e||(e=document.createElement("span"),e.className="post-views",t.appendChild(e)),e.textContent=n(o??0)}}const i=document.querySelectorAll("article.post-entry");await Promise.all(Array.from(i).map(async t=>{const s=t.querySelector(".entry-footer, .post-meta"),o=t.querySelector("a.entry-link, h2 a, .entry-title a");if(!s||!o)return;try{const i=new URL(o.getAttribute("href"),location.origin);if(i.origin!==location.origin)return;let t=s.querySelector(".post-views");t||(t=document.createElement("span"),t.className="post-views",s.appendChild(t)),t.textContent=n(await e(i.pathname)??0)}catch{}}));const o=document.getElementById("gc_site_visitors_week");if(o){const n=await e("TOTAL","week");o.textContent=n===null?"—":t.format(n)}})})()</script></head><body id=top><script>localStorage.getItem("pref-theme")==="dark"?document.body.classList.add("dark"):localStorage.getItem("pref-theme")==="light"?document.body.classList.remove("dark"):window.matchMedia("(prefers-color-scheme: dark)").matches&&document.body.classList.add("dark")</script><header class=header><nav class=nav><div class=logo><a href=https://blog.alipour.eu/ accesskey=h title="AlipourIm journeys (Alt + H)">AlipourIm journeys</a><div class=logo-switches><button id=theme-toggle accesskey=t title="(Alt + T)" aria-label="Toggle theme"><svg id="moon" width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg><svg id="sun" width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></button></div></div><ul id=menu><li><a href=https://blog.alipour.eu/posts/ title=Posts><span>Posts</span></a></li><li><a href=https://blog.alipour.eu/phd_journey/ title=PhD_journey><span>PhD_journey</span></a></li><li><a href=https://blog.alipour.eu/about/ title=About><span>About</span></a></li></ul></nav></header><main class=main><article class=post-single><header class=post-header><div class=breadcrumbs><a href=https://blog.alipour.eu/>Home</a>&nbsp;»&nbsp;<a href=https://blog.alipour.eu/posts/>Posts</a></div><h1 class="post-title entry-hint-parent">INET Logo That Breathes — From CAD to LEDs to a Calm Little Server</h1><div class=post-meta><span title='2025-10-17 00:00:00 +0000 UTC'>October 17, 2025</span>&nbsp;·&nbsp;17 min&nbsp;·&nbsp;Iman Alipour
<span class=post-meta-item><a href=https://blog.alipour.eu/posts/inet_logo/#isso-thread class=isso-comments-link>Comments</a></span></div></header><figure class=entry-cover><img loading=eager src=https://blog.alipour.eu/images/inet/inet_animation_collage.jpg alt="Six looks of the INET LED logo"><figcaption>Design → print → solder → code → glow.</figcaption></figure><div class=post-content><blockquote><p>I didnt add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote><p>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&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p><p>So I crafted the INET logo and gave it a 9-5 job: be a <strong>polite barometer</strong>. 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.</p><p>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&rsquo;ll try to include as much detail as I can.</p><hr><h2 id=1-sketch--cad--print>1) Sketch → CAD → Print<a hidden class=anchor aria-hidden=true href=#1-sketch--cad--print>#</a></h2><p><img alt="Fusion design + logotype" loading=lazy src=/images/inet/inet_logo_fusion_merged.png></p><p>I started the way all sensible hardware projects start: with <strong>overconfidence</strong> 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 <strong>I</strong>, the curving <strong>N</strong>, three stacked bars for <strong>E</strong>, and the long arm of <strong>T</strong>. Each pocket got:</p><ul><li><strong>Mounting bosses</strong> for the front diffusers (M2 screws),</li><li><strong>Cable holes</strong> between chambers (rounded to avoid cutting silicone wire), and</li><li>A small <strong>wiring bay</strong> for the controller and power.</li></ul><p>I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at <strong>1-2 mm</strong> 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.</p><p>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.</p><hr><h2 id=2-the-look-i-wanted>2) The Look I Wanted<a hidden class=anchor aria-hidden=true href=#2-the-look-i-wanted>#</a></h2><p><img alt="Animations collage" loading=lazy src=/images/inet/inet_animation_collage.jpg></p><p>I wasn&rsquo;t building a stage light. The goal was <strong>calm</strong>: 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 <strong>CO₂ monitor</strong> 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&rsquo;m happy with it and want to let it be there doing it&rsquo;s job.</p><hr><h2 id=3-the-tidy-mess-behind-the-panel>3) The Tidy Mess Behind the Panel<a hidden class=anchor aria-hidden=true href=#3-the-tidy-mess-behind-the-panel>#</a></h2><p><img alt="Hardware collage" loading=lazy src=/images/inet/inet_hardware_collage.jpg></p><p>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:</p><ul><li>A <strong>330470 Ω</strong> series resistor on the <em>data</em> line right at the first pixel. It damps ringing and prevents weird flickers.</li><li>A <strong>1000 µF</strong> electrolytic across <strong>5V/GND</strong> at the strip start. It handles inrush so the first LED doesnt faint at boot.</li></ul><p>I route the strip <strong>DIN → DOUT</strong> 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 <strong>before</strong> power and bring brightness up slowly on a bench supply.</p><p>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&rsquo;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</p><hr><h2 id=4-a-web-panel-that-stays-out-of-the-way>4) A Web Panel that Stays Out of the Way<a hidden class=anchor aria-hidden=true href=#4-a-web-panel-that-stays-out-of-the-way>#</a></h2><p><img alt="UI collage" loading=lazy src=/images/inet/inet_ui_collage.jpg></p><p>The web UI is quiet on purpose: a single navbar, a <code>/control</code> page with big samesize buttons, a <code>/schedule</code> table, a draganddrop <code>/calendar</code>, a <code>/status</code> page that updates once a second, and <code>/history</code> 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.</p><p>Theres also a <strong>Safe Mode</strong> switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it&rsquo;s not funny for anyone to fidn this out when looking at your creation. So&mldr; yea, I took precautions not to see people getting seizures looking at my creation. :-)</p><hr><h2 id=5-the-color-language-for-air>5) The Color Language for Air<a hidden class=anchor aria-hidden=true href=#5-the-color-language-for-air>#</a></h2><p>The <code>co2_monitor</code> animation maps CO₂ ppm → color and adds slow motion so it doesnt feel like a stoplight:</p><ul><li><strong>&lt; 500 ppm</strong>: Cyan — outdoorlike fresh air.</li><li><strong>500799</strong>: Green — good.</li><li><strong>8001199</strong>: Yellow — getting stale.</li><li><strong>12001499</strong>: Orange — poor; youll feel it.</li><li><strong>15001999</strong>: Red — ventilate now.</li><li><strong>≥ 2000</strong>: Purple — the logo is politely yelling.</li></ul><p>I blend the readings with an <strong>exponential moving average</strong> and use <strong>hysteresis</strong> 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 <strong>breathing brightness</strong>. The result feels alive but never flashy.</p><p>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&rsquo;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&rsquo;s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.</p><hr><h2 id=6-the-code--a-guided-tour-no-giant-blob-promise>6) The Code — a guided tour (no giant blob, promise)<a hidden class=anchor aria-hidden=true href=#6-the-code--a-guided-tour-no-giant-blob-promise>#</a></h2><p>This whole project runs from a single Python file. Think of it like a tiny orchestra:<br>one thread conducts the <strong>LEDs</strong>, one listens politely to the <strong>CO₂ sensor</strong>, one keeps time as the <strong>scheduler</strong>, and a small <strong>web server</strong> hands you the baton when you want to improvise.</p><p>Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).</p><hr><h3 id=61-configuration-the-knobs>6.1 Configuration (the knobs)<a hidden class=anchor aria-hidden=true href=#61-configuration-the-knobs>#</a></h3><p>Let&rsquo;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.</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span>LED_COUNT<span style=color:#f92672>=</span><span style=color:#ae81ff>78</span>, LED_PIN<span style=color:#f92672>=</span><span style=color:#ae81ff>18</span>, LED_BRIGHT<span style=color:#f92672>=</span><span style=color:#ae81ff>255</span>
</span></span><span style=display:flex><span>SCHEDULE_PATH<span style=color:#f92672>=</span><span style=color:#e6db74>&#34;schedule.json&#34;</span>
</span></span><span style=display:flex><span>DB_PATH<span style=color:#f92672>=</span><span style=color:#e6db74>&#34;sensor.db&#34;</span>
</span></span><span style=display:flex><span>SAFE_MODE<span style=color:#f92672>=</span><span style=color:#66d9ef>True</span> <span style=color:#75715e># hide flashy animations by default</span>
</span></span></code></pre></div><p><em>Why its like this:</em> simple, explicit defaults → less “why is it dark” debugging.</p><hr><h3 id=62-strip-setup--frame-diff-no-flicker-magic>6.2 Strip setup + frame-diff (no flicker magic)<a hidden class=anchor aria-hidden=true href=#62-strip-setup--frame-diff-no-flicker-magic>#</a></h3><p>Now initialize <code>rpi_ws281x.PixelStrip</code> and put a <strong>shadow frame buffer</strong> in front of it.</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span>frame <span style=color:#f92672>=</span> [(<span style=color:#ae81ff>0</span>,<span style=color:#ae81ff>0</span>,<span style=color:#ae81ff>0</span>)] <span style=color:#f92672>*</span> LED_COUNT
</span></span><span style=display:flex><span>_dirty <span style=color:#f92672>=</span> <span style=color:#66d9ef>False</span>
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>_set_pixel</span>(i, r, g, b):
</span></span><span style=display:flex><span> <span style=color:#75715e># only touch hardware if the value actually changed</span>
</span></span><span style=display:flex><span> <span style=color:#66d9ef>if</span> frame[i] <span style=color:#f92672>!=</span> (r,g,b):
</span></span><span style=display:flex><span> strip<span style=color:#f92672>.</span>setPixelColorRGB(i, r, g, b)
</span></span><span style=display:flex><span> frame[i] <span style=color:#f92672>=</span> (r,g,b)
</span></span><span style=display:flex><span> <span style=color:#66d9ef>global</span> _dirty; _dirty <span style=color:#f92672>=</span> <span style=color:#66d9ef>True</span>
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>flush</span>():
</span></span><span style=display:flex><span> <span style=color:#66d9ef>if</span> _dirty: strip<span style=color:#f92672>.</span>show(); _dirty<span style=color:#f92672>=</span><span style=color:#66d9ef>False</span>
</span></span></code></pre></div><p><em>Why its like this:</em> 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 <code>show()</code> with identical data.</p><hr><h3 id=63-a-tiny-color-wheel-helper>6.3 A tiny color wheel helper<a hidden class=anchor aria-hidden=true href=#63-a-tiny-color-wheel-helper>#</a></h3><p>Classic rainbow helper used by a few animations:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>wheel</span>(pos):
</span></span><span style=display:flex><span> <span style=color:#75715e># 0..255 → (r,g,b)</span>
</span></span><span style=display:flex><span> <span style=color:#f92672>...</span>
</span></span></code></pre></div><p><em>Why its like this:</em> I&rsquo;ll use it again and again; it keeps color math out of the animations.</p><hr><h3 id=64-state--orchestration>6.4 State & orchestration<a hidden class=anchor aria-hidden=true href=#64-state--orchestration>#</a></h3><p>Let&rsquo;s hold a registry of animations, a stop flag, and the currently playing thread.</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span>animations <span style=color:#f92672>=</span> {}
</span></span><span style=display:flex><span>stop_event <span style=color:#f92672>=</span> threading<span style=color:#f92672>.</span>Event()
</span></span><span style=display:flex><span>current_thread <span style=color:#f92672>=</span> <span style=color:#66d9ef>None</span>
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>register</span>(name, safe<span style=color:#f92672>=</span><span style=color:#66d9ef>True</span>):
</span></span><span style=display:flex><span> <span style=color:#66d9ef>def</span> <span style=color:#a6e22e>wrap</span>(fn):
</span></span><span style=display:flex><span> animations[name] <span style=color:#f92672>=</span> {<span style=color:#e6db74>&#34;fn&#34;</span>: fn, <span style=color:#e6db74>&#34;safe&#34;</span>: safe}
</span></span><span style=display:flex><span> <span style=color:#66d9ef>return</span> fn
</span></span><span style=display:flex><span> <span style=color:#66d9ef>return</span> wrap
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>run_animation</span>(fn):
</span></span><span style=display:flex><span> <span style=color:#75715e># cooperative hand-off: stop current, start next</span>
</span></span><span style=display:flex><span> <span style=color:#66d9ef>if</span> current_thread <span style=color:#f92672>and</span> current_thread<span style=color:#f92672>.</span>is_alive():
</span></span><span style=display:flex><span> stop_event<span style=color:#f92672>.</span>set(); current_thread<span style=color:#f92672>.</span>join()
</span></span><span style=display:flex><span> stop_event<span style=color:#f92672>.</span>clear()
</span></span><span style=display:flex><span> t <span style=color:#f92672>=</span> threading<span style=color:#f92672>.</span>Thread(target<span style=color:#f92672>=</span>fn, name<span style=color:#f92672>=</span>fn<span style=color:#f92672>.</span>__name__, daemon<span style=color:#f92672>=</span><span style=color:#66d9ef>True</span>)
</span></span><span style=display:flex><span> t<span style=color:#f92672>.</span>start(); globals()[<span style=color:#e6db74>&#34;current_thread&#34;</span>] <span style=color:#f92672>=</span> t
</span></span></code></pre></div><p><em>Why its like this:</em> adding a new animation is a one-liner <code>@register("name")</code>. Clean exits prevent torn frames and half-drawn comets.</p><hr><h3 id=65-animations-the-mood>6.5 Animations (the mood)<a hidden class=anchor aria-hidden=true href=#65-animations-the-mood>#</a></h3><p>Each animation is a loop that checks <code>stop_event.is_set()</code> and draws frames with <code>_set_pixel(...)</code> + <code>flush()</code>.</p><ul><li><strong><code>redpulse</code></strong> — calm breathing in red; great default.</li><li><strong><code>colorwave</code></strong> — slow rainbow that reads clearly behind diffusers.</li><li><strong><code>comet</code></strong> — a single head with a fading tail orbiting the logo.</li></ul><p>They all end with <code>finally: clear_strip()</code> so the panel doesnt freeze on the last pose if you stop mid-frame.</p><p><em>Why its like this:</em> cooperative loops + frame-diff = smooth, interruption-safe effects.</p><hr><h3 id=66-co-color-language-with-smoothing--hysteresis>6.6 CO₂ color language (with smoothing + hysteresis)<a hidden class=anchor aria-hidden=true href=#66-co-color-language-with-smoothing--hysteresis>#</a></h3><p>The star of the show. Now map ppm to color <strong>bands</strong> and keep transitions human-pleasant.</p><ul><li><code>&lt; 500</code>: <strong>Cyan</strong> (fresh)</li><li><code>500799</code>: <strong>Green</strong></li><li><code>8001199</code>: <strong>Yellow</strong></li><li><code>12001499</code>: <strong>Orange</strong></li><li><code>15001999</code>: <strong>Red</strong></li><li><code>≥ 2000</code>: <strong>Purple</strong></li></ul><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span>BANDS<span style=color:#f92672>=</span>[(<span style=color:#ae81ff>0</span>,<span style=color:#ae81ff>500</span>,(<span style=color:#ae81ff>0</span>,<span style=color:#ae81ff>214</span>,<span style=color:#ae81ff>255</span>)), (<span style=color:#ae81ff>500</span>,<span style=color:#ae81ff>800</span>,(<span style=color:#ae81ff>17</span>,<span style=color:#ae81ff>204</span>,<span style=color:#ae81ff>85</span>)), <span style=color:#f92672>...</span> (<span style=color:#ae81ff>2000</span>,<span style=color:#960050;background-color:#1e0010></span>,(<span style=color:#ae81ff>123</span>,<span style=color:#ae81ff>44</span>,<span style=color:#ae81ff>191</span>))]
</span></span><span style=display:flex><span>EMA_ALPHA<span style=color:#f92672>=</span><span style=color:#ae81ff>0.15</span>; HYST<span style=color:#f92672>=</span><span style=color:#ae81ff>35</span>
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#75715e># Every tick:</span>
</span></span><span style=display:flex><span>ema <span style=color:#f92672>=</span> (<span style=color:#ae81ff>1</span><span style=color:#f92672>-</span>EMA_ALPHA)<span style=color:#f92672>*</span>ema <span style=color:#f92672>+</span> EMA_ALPHA<span style=color:#f92672>*</span>co2
</span></span><span style=display:flex><span>band <span style=color:#f92672>=</span> hysteresis_aware_band(ema, last_band, HYST)
</span></span><span style=display:flex><span>target <span style=color:#f92672>=</span> BANDS[band]<span style=color:#f92672>.</span>color
</span></span><span style=display:flex><span>current <span style=color:#f92672>=</span> lerp(current, target, <span style=color:#ae81ff>0.10</span>) <span style=color:#75715e># gentle hue easing</span>
</span></span><span style=display:flex><span>level <span style=color:#f92672>=</span> breathe(<span style=color:#ae81ff>0.25</span> Hz, low<span style=color:#f92672>=</span><span style=color:#ae81ff>10</span>, high<span style=color:#f92672>=</span><span style=color:#ae81ff>220</span>)
</span></span><span style=display:flex><span>draw all pixels <span style=color:#f92672>=</span> current <span style=color:#f92672>*</span> level
</span></span></code></pre></div><p><em>Why its like this:</em> EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.</p><hr><h3 id=67-optional-scd41-sensor-thread-the-polite-listener>6.7 Optional SCD41 sensor thread (the polite listener)<a hidden class=anchor aria-hidden=true href=#67-optional-scd41-sensor-thread-the-polite-listener>#</a></h3><p>If the SCD41 is present, a <strong>dedicated thread</strong> owns I²C and updates a global <code>co2_data</code> dict every few seconds. It also logs to a tiny SQLite database.</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>_start_sensor</span>():
</span></span><span style=display:flex><span> <span style=color:#66d9ef>try</span>:
</span></span><span style=display:flex><span> scd4x <span style=color:#f92672>=</span> adafruit_scd4x<span style=color:#f92672>.</span>SCD4X(i2c); scd4x<span style=color:#f92672>.</span>start_periodic_measurement()
</span></span><span style=display:flex><span> <span style=color:#66d9ef>except</span>: <span style=color:#66d9ef>return</span> <span style=color:#75715e># sensor optional</span>
</span></span><span style=display:flex><span> <span style=color:#66d9ef>def</span> <span style=color:#a6e22e>loop</span>():
</span></span><span style=display:flex><span> <span style=color:#66d9ef>while</span> <span style=color:#66d9ef>True</span>:
</span></span><span style=display:flex><span> time<span style=color:#f92672>.</span>sleep(<span style=color:#ae81ff>5</span>)
</span></span><span style=display:flex><span> <span style=color:#66d9ef>if</span> scd4x<span style=color:#f92672>.</span>data_ready:
</span></span><span style=display:flex><span> co2_data<span style=color:#f92672>.</span>update({<span style=color:#f92672>...</span>})
</span></span><span style=display:flex><span> db<span style=color:#f92672>.</span>insert(timestamp, co2, temperature, humidity)
</span></span><span style=display:flex><span> threading<span style=color:#f92672>.</span>Thread(target<span style=color:#f92672>=</span>loop, daemon<span style=color:#f92672>=</span><span style=color:#66d9ef>True</span>)<span style=color:#f92672>.</span>start()
</span></span></code></pre></div><p><em>Why its like this:</em> one thread owns the bus → no read contention. The controller keeps working even without a sensor.</p><hr><h3 id=68-scheduler--90-minute-override-humans-win-then-reset>6.8 Scheduler + 90-minute override (humans win, then reset)<a hidden class=anchor aria-hidden=true href=#68-scheduler--90-minute-override-humans-win-then-reset>#</a></h3><p>A background thread asks, every few seconds, <strong>which</strong> mode should be running:</p><ol><li>If the user chose something recently (override), respect it for <code>TTL = 90 min</code> (or the duration set in the UI).</li><li>Otherwise, apply the <strong>default schedule</strong> (Wednesday 1417 → <code>slow</code>, else <code>redpulse</code>).</li><li>Save/load the schedule atomically to <code>schedule.json</code>.</li></ol><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>scheduler_pick</span>():
</span></span><span style=display:flex><span> <span style=color:#66d9ef>if</span> now <span style=color:#f92672>&lt;</span> override_until: <span style=color:#66d9ef>return</span> override_mode
</span></span><span style=display:flex><span> <span style=color:#66d9ef>for</span> row <span style=color:#f92672>in</span> load_schedule():
</span></span><span style=display:flex><span> <span style=color:#66d9ef>if</span> matches(now, row): <span style=color:#66d9ef>return</span> row[<span style=color:#e6db74>&#34;mode&#34;</span>]
</span></span><span style=display:flex><span> <span style=color:#66d9ef>return</span> <span style=color:#e6db74>&#34;redpulse&#34;</span>
</span></span></code></pre></div><p><em>Why its like this:</em> you can play DJ for a meeting, and the room quietly returns to its routine afterward.</p><hr><h3 id=69-the-web-panel-tiny-flask-big-buttons>6.9 The web panel (tiny Flask, big buttons)<a hidden class=anchor aria-hidden=true href=#69-the-web-panel-tiny-flask-big-buttons>#</a></h3><p>Three pages, no fuss:</p><ul><li><code>/status</code> — live JSON (<code>/status_data</code>) every second → current mode + CO₂/Temp/Humidity.</li><li><code>/control</code> — grid of same-size buttons (respects <strong>Safe Mode</strong>), optional <strong>duration</strong> (0180 min) with validation.</li><li><code>/schedule</code> — simple table editor; <strong>Add Row</strong> and <strong>Save</strong>; writes atomically.</li></ul><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span><span style=color:#a6e22e>@app.post</span>(<span style=color:#e6db74>&#34;/set&#34;</span>)
</span></span><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>set_mode</span>():
</span></span><span style=display:flex><span> mode <span style=color:#f92672>=</span> request<span style=color:#f92672>.</span>form[<span style=color:#e6db74>&#34;mode&#34;</span>]
</span></span><span style=display:flex><span> minutes <span style=color:#f92672>=</span> clamp( int(form[<span style=color:#e6db74>&#34;duration&#34;</span>]), <span style=color:#ae81ff>0</span>, <span style=color:#ae81ff>180</span> )
</span></span><span style=display:flex><span> set_override(mode, minutes)
</span></span><span style=display:flex><span> run_animation(animations[mode][<span style=color:#e6db74>&#34;fn&#34;</span>])
</span></span><span style=display:flex><span> <span style=color:#66d9ef>return</span> redirect(<span style=color:#e6db74>&#34;/control&#34;</span>)
</span></span></code></pre></div><p><em>Why its like this:</em> minimal routes are easier to maintain and dont compete with the art piece.</p><hr><h3 id=610-boot-choreography>6.10 Boot choreography<a hidden class=anchor aria-hidden=true href=#610-boot-choreography>#</a></h3><p>At startup I:</p><ol><li>Try the <strong>sensor thread</strong> (if hardware is there).</li><li>Start the <strong>scheduler thread</strong>.</li><li>Immediately play <strong>redpulse</strong> (so theres a friendly glow right away).</li><li>Start Flask; on shutdown I <strong>clear the strip</strong>.</li></ol><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span><span style=color:#66d9ef>if</span> __name__ <span style=color:#f92672>==</span> <span style=color:#e6db74>&#34;__main__&#34;</span>:
</span></span><span style=display:flex><span> _start_sensor()
</span></span><span style=display:flex><span> threading<span style=color:#f92672>.</span>Thread(target<span style=color:#f92672>=</span>scheduler_loop, daemon<span style=color:#f92672>=</span><span style=color:#66d9ef>True</span>)<span style=color:#f92672>.</span>start()
</span></span><span style=display:flex><span> run_animation(redpulse)
</span></span><span style=display:flex><span> app<span style=color:#f92672>.</span>run(host<span style=color:#f92672>=</span><span style=color:#e6db74>&#34;0.0.0.0&#34;</span>, port<span style=color:#f92672>=</span><span style=color:#ae81ff>5000</span>)
</span></span></code></pre></div><p><em>Why its like this:</em> the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.</p><hr><h3 id=tldr-but-keep-the-vibes>TL;DR (but keep the vibes)<a hidden class=anchor aria-hidden=true href=#tldr-but-keep-the-vibes>#</a></h3><ul><li><strong>Frame-diff</strong> = no flashes.</li><li><strong>EMA + hysteresis</strong> = smooth, truthful color.</li><li><strong>Breathing</strong> = presence, not signage.</li><li><strong>Threads per thing</strong> (LEDs / sensor / scheduler) = no fights.</li><li><strong>Atomic files</strong> = no corrupted schedules.</li><li><strong>Safe Mode</strong> by default = people > pixels.</li></ul><p>Thats it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.</p><h3 id=why-this-shape-of-code>Why this shape of code?<a hidden class=anchor aria-hidden=true href=#why-this-shape-of-code>#</a></h3><ul><li><strong>One thread per hardware thing.</strong> The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides <em>what</em> to run and when. No bus fights.</li><li><strong>Frame diff.</strong> I only call <code>strip.show()</code> when a pixel actually changes. Thats why it doesnt randomly flash during web requests.</li><li><strong>Atomic schedule saves.</strong> The code writes to a temporary file and replaces the old one so a midsave power cut cant corrupt the schedule.</li></ul><blockquote><p>No, you dont <em>need</em> the sensor. If its not connected, the app still runs; <code>co2_monitor</code> just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don&rsquo;t you agree?</p></blockquote><hr><h2 id=7-sensor-database--what-it-stores-where-it-lives-and-how-far-it-goes>7) Sensor Database — what it stores, where it lives, and how far it goes<a hidden class=anchor aria-hidden=true href=#7-sensor-database--what-it-stores-where-it-lives-and-how-far-it-goes>#</a></h2><p>This project logs room conditions to a <strong>tiny SQLite database</strong> so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.</p><h3 id=whats-stored>Whats stored<a hidden class=anchor aria-hidden=true href=#whats-stored>#</a></h3><p>A single table called <code>readings</code>:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-sql data-lang=sql><span style=display:flex><span><span style=color:#66d9ef>CREATE</span> <span style=color:#66d9ef>TABLE</span> <span style=color:#66d9ef>IF</span> <span style=color:#66d9ef>NOT</span> <span style=color:#66d9ef>EXISTS</span> readings (
</span></span><span style=display:flex><span> <span style=color:#66d9ef>timestamp</span> TEXT <span style=color:#66d9ef>PRIMARY</span> <span style=color:#66d9ef>KEY</span>, <span style=color:#75715e>-- &#34;YYYY-MM-DD HH:MM:SS&#34;
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> co2 INTEGER, <span style=color:#75715e>-- ppm
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> temperature REAL, <span style=color:#75715e>-- °C
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> humidity REAL <span style=color:#75715e>-- %
</span></span></span><span style=display:flex><span><span style=color:#75715e></span>);
</span></span></code></pre></div><ul><li>One row per sample (typically every <strong>560 seconds</strong>; slow it down to reduce writes).</li><li><code>timestamp</code> is the primary key → easy “latest” queries and natural time ordering.</li></ul><h3 id=where-the-file-lives>Where the file lives<a hidden class=anchor aria-hidden=true href=#where-the-file-lives>#</a></h3><ul><li>Path is configurable via env var <code>DB_PATH</code> (see your systemd unit).</li><li>Default: <code>sensor.db</code> in the apps working directory (e.g. <code>/home/pi/inet-led/sensor.db</code>).</li></ul><p>Check size:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>ls -lh /home/pi/inet-led/sensor.db
</span></span></code></pre></div><h3 id=how-writes-happen-and-why-its-safe>How writes happen (and why its safe)<a hidden class=anchor aria-hidden=true href=#how-writes-happen-and-why-its-safe>#</a></h3><ul><li>The <strong>sensor thread</strong> owns I²C and inserts a row only when the sensor reports <code>data_ready</code>.</li><li>Inserts are short and journaling protects against power loss.</li><li>For friendlier read/write concurrency, enable WAL once at startup:</li></ul><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-sql data-lang=sql><span style=display:flex><span>PRAGMA journal_mode <span style=color:#f92672>=</span> WAL;
</span></span><span style=display:flex><span>PRAGMA synchronous <span style=color:#f92672>=</span> NORMAL;
</span></span></code></pre></div><h3 id=typical-size--retention>Typical size & retention<a hidden class=anchor aria-hidden=true href=#typical-size--retention>#</a></h3><p>Back-of-envelope:</p><ul><li>~100200 bytes per row (including overhead).</li><li>1 sample/minute → ~1,440 rows/day → <strong>~150300 KB/day</strong><strong>55110 MB/year</strong>.</li><li>If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).</li></ul><p>Prune old data (keep last 90 days):</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-sql data-lang=sql><span style=display:flex><span><span style=color:#66d9ef>DELETE</span> <span style=color:#66d9ef>FROM</span> readings
</span></span><span style=display:flex><span><span style=color:#66d9ef>WHERE</span> <span style=color:#66d9ef>timestamp</span> <span style=color:#f92672>&lt;</span> date(<span style=color:#e6db74>&#39;now&#39;</span>,<span style=color:#e6db74>&#39;-90 day&#39;</span>);
</span></span></code></pre></div><p>(Optionally run <code>VACUUM;</code> after large deletes to reclaim file space.)</p><h3 id=useful-queries>Useful queries<a hidden class=anchor aria-hidden=true href=#useful-queries>#</a></h3><p><strong>Latest reading:</strong></p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-sql data-lang=sql><span style=display:flex><span><span style=color:#66d9ef>SELECT</span> <span style=color:#f92672>*</span> <span style=color:#66d9ef>FROM</span> readings <span style=color:#66d9ef>ORDER</span> <span style=color:#66d9ef>BY</span> <span style=color:#66d9ef>timestamp</span> <span style=color:#66d9ef>DESC</span> <span style=color:#66d9ef>LIMIT</span> <span style=color:#ae81ff>1</span>;
</span></span></code></pre></div><p><strong>Range for charts (last 7 days):</strong></p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-sql data-lang=sql><span style=display:flex><span><span style=color:#66d9ef>SELECT</span> <span style=color:#f92672>*</span> <span style=color:#66d9ef>FROM</span> readings
</span></span><span style=display:flex><span><span style=color:#66d9ef>WHERE</span> <span style=color:#66d9ef>timestamp</span> <span style=color:#f92672>&gt;=</span> datetime(<span style=color:#e6db74>&#39;now&#39;</span>,<span style=color:#e6db74>&#39;-7 day&#39;</span>)
</span></span><span style=display:flex><span><span style=color:#66d9ef>ORDER</span> <span style=color:#66d9ef>BY</span> <span style=color:#66d9ef>timestamp</span>;
</span></span></code></pre></div><p><strong>Downsample to hourly averages (30 days):</strong></p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-sql data-lang=sql><span style=display:flex><span><span style=color:#66d9ef>SELECT</span> strftime(<span style=color:#e6db74>&#39;%Y-%m-%d %H:00:00&#39;</span>, <span style=color:#66d9ef>timestamp</span>) <span style=color:#66d9ef>AS</span> hour,
</span></span><span style=display:flex><span> <span style=color:#66d9ef>AVG</span>(co2) <span style=color:#66d9ef>AS</span> co2, <span style=color:#66d9ef>AVG</span>(temperature) <span style=color:#66d9ef>AS</span> t, <span style=color:#66d9ef>AVG</span>(humidity) <span style=color:#66d9ef>AS</span> h
</span></span><span style=display:flex><span><span style=color:#66d9ef>FROM</span> readings
</span></span><span style=display:flex><span><span style=color:#66d9ef>WHERE</span> <span style=color:#66d9ef>timestamp</span> <span style=color:#f92672>&gt;=</span> datetime(<span style=color:#e6db74>&#39;now&#39;</span>,<span style=color:#e6db74>&#39;-30 day&#39;</span>)
</span></span><span style=display:flex><span><span style=color:#66d9ef>GROUP</span> <span style=color:#66d9ef>BY</span> hour
</span></span><span style=display:flex><span><span style=color:#66d9ef>ORDER</span> <span style=color:#66d9ef>BY</span> hour;
</span></span></code></pre></div><p><strong>Export to CSV (example via sqlite3 CLI):</strong></p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sqlite3 /home/pi/inet-led/sensor.db <span style=color:#e6db74>&lt;&lt;&#39;SQL&#39;
</span></span></span><span style=display:flex><span><span style=color:#e6db74>.headers on
</span></span></span><span style=display:flex><span><span style=color:#e6db74>.mode csv
</span></span></span><span style=display:flex><span><span style=color:#e6db74>.output /home/pi/inet-led/export_readings.csv
</span></span></span><span style=display:flex><span><span style=color:#e6db74>SELECT * FROM readings ORDER BY timestamp;
</span></span></span><span style=display:flex><span><span style=color:#e6db74>.output stdout
</span></span></span><span style=display:flex><span><span style=color:#e6db74>SQL</span>
</span></span></code></pre></div><h3 id=sd-card-friendliness>SD card friendliness<a hidden class=anchor aria-hidden=true href=#sd-card-friendliness>#</a></h3><ul><li>Choose a sensible interval (e.g., <strong>3060 s</strong>).</li><li>Optionally buffer in memory and write every N samples.</li><li>Prefer WAL mode; periodically prune & vacuum.</li><li>If you care about card wear, place the DB on USB/SSD.</li></ul><h3 id=backups--restore>Backups & restore<a hidden class=anchor aria-hidden=true href=#backups--restore>#</a></h3><p><strong>Nightly backup (simple):</strong></p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sqlite3 /home/pi/inet-led/sensor.db <span style=color:#e6db74>&#34;.backup &#39;/home/pi/backup/sensor-</span><span style=color:#66d9ef>$(</span>date +%F<span style=color:#66d9ef>)</span><span style=color:#e6db74>.db&#39;&#34;</span>
</span></span></code></pre></div><p><strong>Restore:</strong></p><ol><li><code>sudo systemctl stop inet-led</code></li><li>Copy backup file back to <code>/home/pi/inet-led/sensor.db</code></li><li><code>sudo systemctl start inet-led</code></li></ol><h3 id=security--permissions>Security & permissions<a hidden class=anchor aria-hidden=true href=#security--permissions>#</a></h3><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>chown pi:pi /home/pi/inet-led/sensor.db
</span></span><span style=display:flex><span>chmod <span style=color:#ae81ff>600</span> /home/pi/inet-led/sensor.db
</span></span></code></pre></div><p>Keep the app directory non-world-readable.</p><h3 id=is-sqlite-the-right-choice>Is SQLite the right choice?<a hidden class=anchor aria-hidden=true href=#is-sqlite-the-right-choice>#</a></h3><p><strong>Yes, for a single Pi</strong> logging every few seconds and rendering local charts:</p><ul><li>✅ Zero admin, a single file, reliable journaling, great performance at this scale.</li><li>⚠️ One writer at a time (I only have the sensor thread writing, so its fine).</li><li>⬆️ 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.</li></ul><h3 id=tldr>TL;DR<a hidden class=anchor aria-hidden=true href=#tldr>#</a></h3><p>SQLite is perfect here: <strong>simple, robust, easy to back up</strong>. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.</p><hr><h2 id=8-run-at-boot-systemd>8) Run at Boot (systemd)<a hidden class=anchor aria-hidden=true href=#8-run-at-boot-systemd>#</a></h2><p>I wrote this simple systemd to <code>/etc/systemd/system/inet-led.service</code> so that it runs the script when RPi boots up:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-ini data-lang=ini><span style=display:flex><span><span style=color:#66d9ef>[Unit]</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>Description</span><span style=color:#f92672>=</span><span style=color:#e6db74>INET LED Panel</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>After</span><span style=color:#f92672>=</span><span style=color:#e6db74>network.target</span>
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#66d9ef>[Service]</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>ExecStart</span><span style=color:#f92672>=</span><span style=color:#e6db74>/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>WorkingDirectory</span><span style=color:#f92672>=</span><span style=color:#e6db74>/home/pi/inet-led</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>Restart</span><span style=color:#f92672>=</span><span style=color:#e6db74>always</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>User</span><span style=color:#f92672>=</span><span style=color:#e6db74>pi</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>Environment</span><span style=color:#f92672>=</span><span style=color:#e6db74>LED_COUNT=78</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>Environment</span><span style=color:#f92672>=</span><span style=color:#e6db74>SCHEDULE_FILE=/home/pi/inet-led/schedule.json</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>Environment</span><span style=color:#f92672>=</span><span style=color:#e6db74>DB_PATH=/home/pi/inet-led/sensor.db</span>
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#66d9ef>[Install]</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>WantedBy</span><span style=color:#f92672>=</span><span style=color:#e6db74>multi-user.target</span>
</span></span></code></pre></div><p>Then you can run:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sudo systemctl daemon-reload
</span></span><span style=display:flex><span>sudo systemctl enable --now inet-led
</span></span><span style=display:flex><span>journalctl -u inet-led -f
</span></span></code></pre></div><p>to control the systemd service.</p><hr><h2 id=9-soldering-notes-a-love-letter-to-hot-glue>9) Soldering Notes (a love letter to hot glue)<a hidden class=anchor aria-hidden=true href=#9-soldering-notes-a-love-letter-to-hot-glue>#</a></h2><ul><li><strong>Tin first, solder second.</strong> Tiny pads like tiny puddles. Fast in/out, no lifted pads.</li><li><strong>Stagger joints</strong> so nothing stacks under the diffuser.</li><li><strong>Heatshrink + a dot of hot glue</strong> = happier future you.</li><li><strong>Continuity before power.</strong> Multimeter first, electrons last.</li><li>If a pad <em>does</em> lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.</li></ul><p>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&mldr; So&mldr; yea.</p><hr><h2 id=10-why-these-design-choices>10) Why these design choices?<a hidden class=anchor aria-hidden=true href=#10-why-these-design-choices>#</a></h2><ul><li><strong>Calm motion, not flashy.</strong> Meeting rooms breed fatigue; the light should be a helper, not a distraction.</li><li><strong>Cyan→Purple language.</strong> Easy to learn, visible at a glance, meaningful without numbers.</li><li><strong>White charts, dark UI.</strong> Data should be legible on a projector; buttons shouldnt shout.</li><li><strong>Safe Mode default.</strong> People first. Demos are optin.</li><li><strong>PLA body, PLA diffuser.</strong> Fast and easy rapid prototyping, easy and fast printing.</li></ul><hr><h2 id=11-what-i-learned>11) What I learned<a hidden class=anchor aria-hidden=true href=#11-what-i-learned>#</a></h2><ul><li>A logo is a better messenger than a dashboard.</li><li>Everyone knows what <strong>orange</strong> means without a legend.</li><li>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!</li><li>Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^</li></ul><hr><h2 id=12-final-notes>12) Final notes<a hidden class=anchor aria-hidden=true href=#12-final-notes>#</a></h2><p>I did this project as a fun distraction and &ldquo;not work&rdquo; 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&rsquo;m so so thankful of my advisor for this cool idea, and all the support. It&rsquo;s not the first time I&rsquo;ve been gifted such toys, and as I have recieved other things after that, I know it&rsquo;s not the last.</p><p>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&rsquo;s hope that doesn&rsquo;t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*</p><p>The whole project took around 8 days, 4 of which were part of a long weekend. I did do some &ldquo;not work&rdquo; as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don&rsquo;t know how the group feels/thinks about the logo, but I love it! I hope it won&rsquo;t die after I leave, but even if so, so be it. I had my fun with it. :)</p><hr><div class=signature-block style=margin-top:1rem><p><strong>Downloads:</strong>
<a href=/sources/posts/inet_logo.md>Markdown</a> ·
<a href=/sources/posts/inet_logo.md.asc>Signature (.asc)</a></p><details class=signature><summary>View OpenPGP signature</summary><pre style=font-size:.85em;overflow-x:auto;padding:.75rem>-----BEGIN PGP SIGNATURE-----
iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
jSoc5w/&#43;Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
0oWl2ZZ6FHKdAo2&#43;gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC&#43;9J
CgcvqbZdC&#43;M2c3PcPPA4UV5reYclf9PisEsmJSiR&#43;cyDaCtNJkYjQ9SSZMO&#43;BV93
k6I20tEILeR/l72ahSGyGCQxFTkI&#43;cE5EOglG&#43;AJP7HnLArcBUplixjLS7j/gq13
U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe&#43;kXXv1wH1yE
sfJnMxiQ2YJqxUXDjJNJt1R&#43;4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
sDsV6dgcPJUSvpuZqPmQicTu/&#43;BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
QUuBm7o7sHpSUax7CEE&#43;vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK&#43;9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
Bs&#43;BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh&#43;hXljpEPyQ/Mq3s0X
o9h8Z1rq9m7nIwmpLNW&#43;vEiL81/SrnjJE8/&#43;kA/&#43;J2p9TNBYcn8=
=tAeg
-----END PGP SIGNATURE-----
</pre></details><p><em>Verify locally:</em></p><pre style=font-size:.85em;overflow-x:auto;padding:.75rem>curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
gpg --verify inet_logo.md.asc inet_logo.md
</pre></div></div><footer class=post-footer><ul class=post-tags><li><a href=https://blog.alipour.eu/tags/raspberrypi/>Raspberrypi</a></li><li><a href=https://blog.alipour.eu/tags/ws2812/>Ws2812</a></li><li><a href=https://blog.alipour.eu/tags/scd41/>Scd41</a></li><li><a href=https://blog.alipour.eu/tags/iot/>Iot</a></li><li><a href=https://blog.alipour.eu/tags/flask/>Flask</a></li><li><a href=https://blog.alipour.eu/tags/fabrication/>Fabrication</a></li><li><a href=https://blog.alipour.eu/tags/soldering/>Soldering</a></li></ul><nav class=paginav><a class=prev href=https://blog.alipour.eu/posts/papermod-views-debugging-story/><span class=title>« Prev</span><br><span>A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)</span>
</a><a class=next href=https://blog.alipour.eu/posts/scent_of_a_woman/><span class=title>Next »</span><br><span>Movie review: Scent of a Woman</span></a></nav></footer><div class=comments-switch style="display:flex;gap:.5rem;margin:.5rem 0 1rem"><button id=btn-isso type=button aria-pressed=true>Anonymous (Isso)</button>
<button id=btn-giscus type=button aria-pressed=false>GitHub (Giscus)</button>
<a href=https://github.com/AlipourIm/blog-comments/discussions/categories/comments target=_blank rel=noopener>Open on GitHub ↗</a></div><div id=panel-giscus style=display:none><section id=giscus-thread></section></div><div id=panel-isso><section id=isso-thread data-isso-id=/posts/inet_logo/ data-title="INET Logo That Breathes — From CAD to LEDs to a Calm Little Server"><noscript>Javascript needs to be activated to view comments.</noscript></section><script src=/isso/js/embed.min.js data-isso=/isso/ data-isso-css=false data-isso-lang=en data-isso-max-comments-nested=5 data-isso-sorting=newest async></script><small class=isso-powered style="display:block;margin:.5rem 0;color:var(--secondary,#888)">Comments powered by <a href=https://isso-comments.de target=_blank rel=noopener>Isso</a></small></div><script>(function(){var i=document.getElementById("panel-isso"),a=document.getElementById("panel-giscus"),e=document.getElementById("btn-isso"),t=document.getElementById("btn-giscus"),s=!1;function r(){if(n=localStorage.getItem("pref-theme"),n==="dark")return!0;if(n==="light")return!1;var n,e=document.documentElement,t=document.body;return!!(e.classList&&e.classList.contains("dark")||t.classList&&t.classList.contains("dark")||e.getAttribute("data-theme")==="dark"||t.getAttribute("data-theme")==="dark"||e.classList&&e.classList.contains("theme-dark")||t.classList&&t.classList.contains("theme-dark"))||window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches}function o(){return r()?"dark_dimmed":"noborder_light"}function c(){if(s)return;var t,n,e=document.createElement("script");e.src="https://giscus.app/client.js",e.async=!0,e.crossOrigin="anonymous",e.setAttribute("data-repo","AlipourIm/blog-comments"),e.setAttribute("data-repo-id","R_kgDOQGARyA"),e.setAttribute("data-category","Comments"),e.setAttribute("data-category-id","DIC_kwDOQGARyM4Cw3x-"),e.setAttribute("data-mapping","pathname"),e.setAttribute("data-strict","0"),e.setAttribute("data-reactions-enabled","1"),e.setAttribute("data-emit-metadata","0"),e.setAttribute("data-input-position","bottom"),e.setAttribute("data-lang","en"),e.setAttribute("data-theme",o()),document.getElementById("giscus-thread").appendChild(e);function i(){var e=document.querySelector("iframe.giscus-frame");e&&e.contentWindow&&e.contentWindow.postMessage({giscus:{setConfig:{theme:o()}}},"https://giscus.app")}t=document.getElementById("theme-toggle"),t&&t.addEventListener("click",function(){setTimeout(i,0)}),n=new MutationObserver(function(){i()}),n.observe(document.documentElement,{attributes:!0,attributeFilter:["class","data-theme"]}),n.observe(document.body,{attributes:!0,attributeFilter:["class","data-theme"]}),s=!0}function n(n){var s=n==="isso";i.style.display=s?"block":"none",a.style.display=s?"none":"block",e&&e.setAttribute("aria-pressed",s?"true":"false"),t&&t.setAttribute("aria-pressed",s?"false":"true"),s||c()}e&&e.addEventListener("click",function(e){e.preventDefault(),n("isso")}),t&&t.addEventListener("click",function(e){e.preventDefault(),n("giscus")}),n("isso")})()</script></article></main><footer class=footer><span>&copy; 2026 <a href=https://blog.alipour.eu/>AlipourIm journeys</a></span> ·
<span>Powered by
<a href=https://gohugo.io/ rel="noopener noreferrer" target=_blank>Hugo</a> &
<a href=https://github.com/adityatelange/hugo-PaperMod/ rel=noopener target=_blank>PaperMod</a></span></footer><a href=#top aria-label="go to top" title="Go to Top (Alt + G)" class=top-link id=top-link accesskey=g><svg viewBox="0 0 12 6" fill="currentcolor"><path d="M12 6H0l6-6z"/></svg></a><div class=site-meta-row style=display:flex;align-items:center;gap:.75rem;flex-wrap:wrap><a href=/index.xml rel=alternate type=application/rss+xml title=RSS class=rss-link style=display:inline-flex;align-items:center;gap:.35rem><svg viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/><circle cx="5" cy="19" r="1"/></svg>
<span>RSS</span>
</a><span aria-hidden=true>·</span>
<span>Visitors this week: <span id=gc_site_visitors_week></span></span></div><script>let menu=document.getElementById("menu");menu&&(menu.scrollLeft=localStorage.getItem("menu-scroll-position"),menu.onscroll=function(){localStorage.setItem("menu-scroll-position",menu.scrollLeft)}),document.querySelectorAll('a[href^="#"]').forEach(e=>{e.addEventListener("click",function(e){e.preventDefault();var t=this.getAttribute("href").substr(1);window.matchMedia("(prefers-reduced-motion: reduce)").matches?document.querySelector(`[id='${decodeURIComponent(t)}']`).scrollIntoView():document.querySelector(`[id='${decodeURIComponent(t)}']`).scrollIntoView({behavior:"smooth"}),t==="top"?history.replaceState(null,null," "):history.pushState(null,null,`#${t}`)})})</script><script>var mybutton=document.getElementById("top-link");window.onscroll=function(){document.body.scrollTop>800||document.documentElement.scrollTop>800?(mybutton.style.visibility="visible",mybutton.style.opacity="1"):(mybutton.style.visibility="hidden",mybutton.style.opacity="0")}</script><script>document.getElementById("theme-toggle").addEventListener("click",()=>{document.body.className.includes("dark")?(document.body.classList.remove("dark"),localStorage.setItem("pref-theme","light")):(document.body.classList.add("dark"),localStorage.setItem("pref-theme","dark"))})</script><script>document.querySelectorAll("pre > code").forEach(e=>{const n=e.parentNode.parentNode,t=document.createElement("button");t.classList.add("copy-code"),t.innerHTML="copy";function s(){t.innerHTML="copied!",setTimeout(()=>{t.innerHTML="copy"},2e3)}t.addEventListener("click",t=>{if("clipboard"in navigator){navigator.clipboard.writeText(e.textContent),s();return}const n=document.createRange();n.selectNodeContents(e);const o=window.getSelection();o.removeAllRanges(),o.addRange(n);try{document.execCommand("copy"),s()}catch{}o.removeRange(n)}),n.classList.contains("highlight")?n.appendChild(t):n.parentNode.firstChild==n||(e.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName=="TABLE"?e.parentNode.parentNode.parentNode.parentNode.parentNode.appendChild(t):e.parentNode.appendChild(t))})</script></body></html>