added air quality monitor blogpost

This commit is contained in:
Administrator 2025-11-24 10:35:10 -06:00
parent 7545521c6c
commit 332b9498f6
6 changed files with 674 additions and 622 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -0,0 +1,672 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Building an Air Quality Monitor - Tech Blog</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--deep-navy: #0A1128;
--rich-gold: #D4AF37;
--warm-gold: #F4E5C2;
--cream: #FFF8E7;
--charcoal: #2C2C2C;
--silver: #C0C0C0;
--burgundy: #7C2D37;
}
body {
font-family: 'Didot', 'Bodoni MT', 'Playfair Display', Georgia, serif;
line-height: 1.8;
color: var(--charcoal);
background: linear-gradient(180deg, var(--deep-navy) 0%, #1a2847 100%);
position: relative;
overflow-x: hidden;
}
/* Art Deco geometric background pattern */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(212, 175, 55, 0.03) 35px, rgba(212, 175, 55, 0.03) 70px),
repeating-linear-gradient(-45deg, transparent, transparent 35px, rgba(212, 175, 55, 0.03) 35px, rgba(212, 175, 55, 0.03) 70px);
z-index: -1;
}
/* Navigation */
nav {
background: rgba(10, 17, 40, 0.95);
border-bottom: 2px solid var(--rich-gold);
padding: 1.0rem 0;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(10px);
}
.nav-content {
max-width: 1000px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-logo {
font-size: 1.5rem;
font-weight: 300;
color: var(--warm-gold);
text-decoration: none;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.nav-back {
color: var(--silver);
text-decoration: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.9rem;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: color 0.3s;
}
.nav-back:hover {
color: var(--rich-gold);
}
.nav-back::before {
content: '←';
margin-right: 0.5rem;
}
/* Article Container */
.article-container {
max-width: 1000px;
margin: 4rem auto;
padding: 0 2rem;
}
/* Article Header */
.article-header {
background: var(--cream);
padding: 4rem 4rem 3rem;
border: 2px solid var(--rich-gold);
position: relative;
margin-bottom: 3rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
/* Corner decorations */
.article-header::before,
.article-header::after {
content: '';
position: absolute;
width: 60px;
height: 60px;
border: 2px solid var(--rich-gold);
}
.article-header::before {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
.article-header::after {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
}
.article-number {
font-size: 4rem;
font-weight: 300;
color: var(--rich-gold);
line-height: 1;
margin-bottom: 1rem;
}
.article-meta {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.15em;
}
.article-category {
color: var(--burgundy);
font-weight: 700;
position: relative;
padding-left: 1rem;
}
.article-category::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%) rotate(45deg);
width: 6px;
height: 6px;
background: var(--rich-gold);
}
.article-title {
font-size: 3.5rem;
font-weight: 400;
color: var(--deep-navy);
line-height: 1.2;
letter-spacing: 0.02em;
margin-bottom: 1.5rem;
}
.article-subtitle {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 1.25rem;
line-height: 1.6;
color: var(--charcoal);
font-weight: 400;
}
/* Tech Stack */
.tech-stack {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid var(--rich-gold);
}
.tech-item {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.9rem;
font-weight: 600;
color: var(--deep-navy);
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, rgba(212, 175, 55, 0.15), rgba(212, 175, 55, 0.05));
border-left: 3px solid var(--rich-gold);
letter-spacing: 0.05em;
}
/* Featured Image */
.featured-image {
width: 100%;
height: 500px;
background: linear-gradient(135deg, var(--deep-navy) 0%, var(--burgundy) 100%);
border: 2px solid var(--rich-gold);
margin-bottom: 3rem;
position: relative;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
/* Sunburst pattern on image */
.featured-image::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 200%;
height: 200%;
background: repeating-conic-gradient(
from 0deg,
rgba(212, 175, 55, 0.1) 0deg 10deg,
transparent 10deg 20deg
);
transform: translate(-50%, -50%);
}
.image-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--warm-gold);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
z-index: 1;
}
.image-placeholder-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.7;
}
.image-placeholder-text {
font-size: 1.2rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* Article Content */
article {
background: var(--cream);
padding: 4rem;
border: 2px solid var(--rich-gold);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 1.1rem;
line-height: 1.8;
color: var(--charcoal);
}
article p {
margin-bottom: 1.5rem;
}
article h2 {
font-family: 'Didot', 'Bodoni MT', 'Playfair Display', Georgia, serif;
font-size: 2.5rem;
font-weight: 400;
color: var(--deep-navy);
margin: 3rem 0 1.5rem;
position: relative;
padding-bottom: 1rem;
}
article h2::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100px;
height: 2px;
background: linear-gradient(to right, var(--rich-gold), transparent);
}
article h3 {
font-family: 'Didot', 'Bodoni MT', 'Playfair Display', Georgia, serif;
font-size: 1.8rem;
font-weight: 400;
color: var(--deep-navy);
margin: 2.5rem 0 1rem;
}
article ul, article ol {
margin: 1.5rem 0;
padding-left: 2rem;
}
article li {
margin-bottom: 1rem;
padding-left: 0.5rem;
}
article ul li::marker {
color: var(--rich-gold);
}
article ol li::marker {
color: var(--rich-gold);
font-weight: 700;
}
article a {
color: var(--burgundy);
text-decoration: none;
border-bottom: 1px solid var(--burgundy);
transition: all 0.3s;
}
article a:hover {
color: var(--rich-gold);
border-bottom-color: var(--rich-gold);
}
/* Decorative dividers */
.deco-divider {
display: flex;
align-items: center;
justify-content: center;
margin: 3rem 0;
position: relative;
}
.deco-divider::before,
.deco-divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, var(--rich-gold), transparent);
}
.diamond {
width: 12px;
height: 12px;
background: var(--rich-gold);
transform: rotate(45deg);
margin: 0 2rem;
}
/* Blockquote */
blockquote {
margin: 2.5rem 0;
padding: 2rem 3rem;
background: linear-gradient(135deg, rgba(212, 175, 55, 0.08), rgba(212, 175, 55, 0.03));
border-left: 4px solid var(--rich-gold);
font-style: italic;
font-size: 1.2rem;
color: var(--deep-navy);
position: relative;
}
blockquote::before {
content: '"';
position: absolute;
top: 1rem;
left: 1rem;
font-size: 4rem;
color: var(--rich-gold);
opacity: 0.3;
font-family: Georgia, serif;
line-height: 1;
}
/* Code blocks */
pre {
background: var(--deep-navy);
border: 1px solid var(--rich-gold);
border-radius: 4px;
padding: 2rem;
margin: 2rem 0;
overflow-x: auto;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
}
code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
line-height: 1.6;
color: var(--warm-gold);
}
/* Footer */
footer {
text-align: center;
padding: 4rem 2rem;
color: var(--warm-gold);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.9rem;
letter-spacing: 0.1em;
position: relative;
}
.footer-ornament {
width: 100px;
height: 2px;
background: linear-gradient(to right, transparent, var(--rich-gold), transparent);
margin: 0 auto 2rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.article-header {
padding: 2rem 1.5rem;
}
.article-title {
font-size: 2.5rem;
}
.article-subtitle {
font-size: 1rem;
}
article {
padding: 2rem 1.5rem;
}
article h2 {
font-size: 2rem;
}
article h3 {
font-size: 1.5rem;
}
.featured-image {
height: 300px;
}
.nav-content {
padding: 0 1rem;
}
.article-container {
padding: 0 1rem;
margin: 2rem auto;
}
}
</style>
</head>
<body>
<!-- Navigation -->
<nav>
<div class="nav-content">
<a href="#" class="nav-logo">Projects</a>
<a href="#" class="nav-back">Back to Home</a>
</div>
</nav>
<!-- Article Container -->
<div class="article-container">
<!-- Article Header -->
<header class="article-header">
<div class="article-number">01</div>
<div class="article-meta">
<span class="article-category">IoT & Hardware</span>
<span class="article-date">2025</span>
</div>
<h1 class="article-title">Building an Air Quality Monitor</h1>
<p class="article-subtitle">
Measuring home air quality for health and better understanding of my environment.
</p>
<!-- Tech Stack -->
<div class="tech-stack">
<span class="tech-item">ESP32</span>
<span class="tech-item">BME280</span>
<span class="tech-item">SPS30</span>
<span class="tech-item">SCD40</span>
<span class="tech-item">InfluxDB</span>
<span class="tech-item">I2C</span>
</div>
</header>
<!-- Featured Image -->
<div class="featured-image">
<div class="image-placeholder">
<div class="image-placeholder-icon"></div>
<div class="image-placeholder-text">Air Quality Sensor Array</div>
</div>
</div>
<!-- Article Content -->
<article>
<h2>The Inspiration</h2>
<p>
Many years ago, I watched a <a href="https://youtu.be/1Nh_vxpycEA" target="_blank">Tom Scott video</a> about how air quality can affect cognitive performance.
From that point on, I felt very conscientious(paranoid) about the quality of air in my home.
To boot, I don't have centralized air, so the idea that my home might
have more carbon dioxide than necessary is a concern.
</p>
<p>
Rather than let this rational concept vex me for the foreseeable future, I figured I would put my
skills to the test and actually measure my indoor air quality.
</p>
<div class="deco-divider">
<div class="diamond"></div>
</div>
<h2>The Solution</h2>
<p>
My solution: multiple small devices placed around my home, taking readings from the air
and aggregating data into a dashboard to show me what the problems are and in what areas. Easy
enough, surely.
</p>
<p>
I bought an ESP32 dev board (DOIT v1) which would serve as a microcontroller that
could connect to the internet and relay data back to a server. I also purchased an SCD40 from
Digikey, an I2C module for detecting CO2. Since I would be taking periodic samples of the air,
the slowness of I2C was not a problem. In fact, it would allow me to add modules as needed during
the prototyping phase.
</p>
<h3>Initial Implementation</h3>
<p>
Thankfully, there were already premade libraries by Adafruit for the SCD40. It made programming
a cinch. I could take periodic readings and send the data to a time-series database (InfluxDB)
using nothing but POST requests. No heavy lifting from the ESP32—I could do all my querying on
my homelab.
</p>
<img src="CO2_levels.png">
<p>
The initial CO2 readings were not great, but not particularly concerning or unusual for the time
of year. There was also an issue of calibration, but I'll talk about that later.
</p>
<div class="deco-divider">
<div class="diamond"></div>
</div>
<h2>Expanding the Sensor Array</h2>
<p>
I wasn't quite satisfied yet. The National Weather Service measures things like Carbon Dioxide
(CO2), Ozone (O3), and many Volatile Organic Compounds (VOCs) such as smoke or exhaust from cars.
In the summer, Canada has had some wildfires which produce a lot of VOCs. It would be good to
know how much is leaking into my home. Also, any fumes or exhaust that my oven produces can have
an effect on air quality. I have a small animal, which are much more sensitive and vulnerable to these.
</p>
<p>
For this, I purchased an SPS30 from Digikey, as well as a BME280 to measure things like temperature,
humidity, and air pressure. Thanks to the I2C design, it was as simple as plug and play (and code).
A real boon for prototyping.
</p>
<h3>Testing and Validation</h3>
<img src="open_window.png" style="width:100%; height:100%; object-fit: cover;">
<p>
I tested the system by opening my window. There was a clear drop in temperature, CO2 levels, and
humidity levels. This is actually recommended by the manufacturers to help calibrate the sensors.
</p>
<div class="deco-divider">
<div class="diamond"></div>
</div>
<h2>The Calibration Challenge</h2>
<p>
This is the tricky part. These components work well and were programmed easily. Knowing that
they're accurate is the hard part, and that's the real product that actual air quality sensor
companies create. This is why I didn't show the PM (particulate matter) graphs from the SPS30—I
have no clue how accurate they are.
</p>
<p>
At the very least, I can verify the humidity and temperature readings and approximate the CO2
from the weather report. The SPS30 also takes about a week to fully calibrate internally.
</p>
<blockquote>
That's really the drawback of building your own air quality monitor. It's much cheaper and you
can configure things how you like with no worry about malicious things like data stealing, but
you have no idea how accurate it is.
</blockquote>
<p>
When you buy a pre-configured air quality monitor, what you're really buying is peace of mind.
You can be assured that the monitors are calibrated properly and to within specifications (assuming
you buy from a reputable company). Mine seems accurate, but without proper tooling, I have little
way of knowing.
</p>
<div class="deco-divider">
<div class="diamond"></div>
</div>
<h2>Next Steps: PCB Design</h2>
<p>
Since this is a prototype, I can't keep it forever. It's wasting my precious breakout boards.
Also, it's a liability when you have a pet who loves stringy things. I took it upon myself to
whip up a circuit design schematic. From here, I could just send the design to some PCB manufacturing
website and they would send me the board. All I would have to do is solder on the components.
</p>
<img src="pcb-schematic.png">
<p>
But before I pull the trigger on that, I want to take some time to both calibrate the parts and
decide if I think they provide enough of a picture of the quality of my air. Maybe I might want
some spectroscopy sensor to detect certain gases and their quantity.
</p>
<h3>Outdoor Monitoring Concept</h3>
<p>
I also had the idea that I could get a pretty good indication of their calibration (and how badly
insulated my home is) if I made a monitor for outside. There are more challenges involved with this.
I need to design some sort of casing that can keep the elements off of the board while also being
able to let it breathe and actually read the air.
</p>
<p>
I learned an old solder quick-fix once: you could take clear acrylic nail polish and coat traces
on boards to make them non-conductive on those spots. Dipping a whole board in nail polish might
not be the right move either, but maybe some Scotchgard might help. I'll have to do some research
and experimenting before I pull the trigger on that either.
</p>
<div class="deco-divider">
<div class="diamond"></div>
</div>
<h2>Future Enhancements</h2>
<p>
The last thing I would want to work on would be OTA (Over The Air) updates. It can be kind of a
pain to connect it and flash the firmware every time. If I could configure OTA, that would solve
that hassle.
</p>
<p>
If I were to fully dive into this multiple monitor configuration, I might have to consider creating
a server for them to "phone home" to. I plug in a domain name and they all register in the same
little UI. This would make updating and version tracking seamless.
</p>
<p>
This project demonstrates that building DIY environmental monitoring systems is accessible to
makers and engineers, but it also highlights the real value proposition of commercial products:
calibration and validation. The journey continues, with PCB design, weatherproofing, and OTA
updates on the horizon.
</p>
</article>
</div>
<!-- Footer -->
<footer>
<div class="footer-ornament"></div>
<p>Tech Chronicles 2025</p>
</footer>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -460,8 +460,8 @@
<!-- Navigation -->
<nav>
<div class="nav-content">
<a href="#" class="nav-logo">Tech Chronicles</a>
<a href="#" class="nav-back">Back to Articles</a>
<a href="#" class="nav-logo">Projects</a>
<a href="https://steen.run/blog/" class="nav-back">Back to Home</a>
</div>
</nav>

View File

@ -1,620 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Article - Tech Blog</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--deep-navy: #0A1128;
--rich-gold: #D4AF37;
--warm-gold: #F4E5C2;
--cream: #FFF8E7;
--charcoal: #2C2C2C;
--silver: #C0C0C0;
--burgundy: #7C2D37;
}
body {
font-family: 'Didot', 'Bodoni MT', 'Playfair Display', Georgia, serif;
line-height: 1.8;
color: var(--charcoal);
background: linear-gradient(180deg, var(--deep-navy) 0%, #1a2847 100%);
position: relative;
overflow-x: hidden;
}
/* Art Deco geometric background pattern */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(212, 175, 55, 0.03) 35px, rgba(212, 175, 55, 0.03) 70px),
repeating-linear-gradient(-45deg, transparent, transparent 35px, rgba(212, 175, 55, 0.03) 35px, rgba(212, 175, 55, 0.03) 70px);
z-index: -1;
}
/* Navigation */
nav {
background: rgba(10, 17, 40, 0.95);
border-bottom: 2px solid var(--rich-gold);
padding: 1.5rem 0;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(10px);
}
.nav-content {
max-width: 1000px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-logo {
font-size: 1.5rem;
font-weight: 300;
color: var(--warm-gold);
text-decoration: none;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.nav-back {
color: var(--silver);
text-decoration: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.9rem;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: color 0.3s;
}
.nav-back:hover {
color: var(--rich-gold);
}
.nav-back::before {
content: '←';
margin-right: 0.5rem;
}
/* Article Container */
.article-container {
max-width: 1000px;
margin: 4rem auto;
padding: 0 2rem;
}
/* Article Header */
.article-header {
background: var(--cream);
padding: 4rem 4rem 3rem;
border: 2px solid var(--rich-gold);
position: relative;
margin-bottom: 3rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
/* Corner decorations */
.article-header::before,
.article-header::after {
content: '';
position: absolute;
width: 60px;
height: 60px;
border: 2px solid var(--rich-gold);
}
.article-header::before {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
.article-header::after {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
}
.article-number {
font-size: 4rem;
font-weight: 300;
color: var(--rich-gold);
line-height: 1;
margin-bottom: 1rem;
}
.article-meta {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.15em;
}
.article-category {
color: var(--burgundy);
font-weight: 700;
position: relative;
padding-left: 1rem;
}
.article-category::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%) rotate(45deg);
width: 6px;
height: 6px;
background: var(--rich-gold);
}
.article-title {
font-size: 3.5rem;
font-weight: 400;
color: var(--deep-navy);
line-height: 1.2;
letter-spacing: 0.02em;
margin-bottom: 1.5rem;
}
.article-subtitle {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 1.25rem;
line-height: 1.6;
color: var(--charcoal);
font-weight: 400;
}
/* Tech Stack */
.tech-stack {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid var(--rich-gold);
}
.tech-item {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.9rem;
font-weight: 600;
color: var(--deep-navy);
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, rgba(212, 175, 55, 0.15), rgba(212, 175, 55, 0.05));
border-left: 3px solid var(--rich-gold);
letter-spacing: 0.05em;
}
/* Featured Image */
.featured-image {
width: 100%;
height: 500px;
background: linear-gradient(135deg, var(--deep-navy) 0%, var(--burgundy) 100%);
border: 2px solid var(--rich-gold);
margin-bottom: 3rem;
position: relative;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
/* Sunburst pattern on image */
.featured-image::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 200%;
height: 200%;
background: repeating-conic-gradient(
from 0deg,
rgba(212, 175, 55, 0.1) 0deg 10deg,
transparent 10deg 20deg
);
transform: translate(-50%, -50%);
}
.image-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 6rem;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.5));
}
/* Article Content */
.article-content {
background: var(--cream);
padding: 4rem;
border: 2px solid var(--rich-gold);
position: relative;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
/* Corner decorations for content */
.article-content::before,
.article-content::after {
content: '';
position: absolute;
width: 40px;
height: 40px;
border: 2px solid var(--rich-gold);
}
.article-content::before {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
}
.article-content::after {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
}
.article-content h2 {
font-size: 2rem;
font-weight: 400;
color: var(--deep-navy);
margin: 3rem 0 1.5rem 0;
letter-spacing: 0.02em;
position: relative;
padding-bottom: 1rem;
}
.article-content h2::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 80px;
height: 2px;
background: var(--rich-gold);
}
.article-content h2:first-child {
margin-top: 0;
}
.article-content h3 {
font-size: 1.5rem;
font-weight: 400;
color: var(--burgundy);
margin: 2.5rem 0 1rem 0;
letter-spacing: 0.02em;
}
.article-content p {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 1.1rem;
line-height: 1.9;
margin-bottom: 1.5rem;
color: var(--charcoal);
}
.article-content ul,
.article-content ol {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 1.1rem;
line-height: 1.9;
margin: 1.5rem 0 1.5rem 2rem;
color: var(--charcoal);
}
.article-content li {
margin-bottom: 0.75rem;
padding-left: 0.5rem;
}
.article-content ul li::marker {
color: var(--rich-gold);
}
.article-content code {
font-family: 'Courier New', monospace;
background: rgba(212, 175, 55, 0.1);
padding: 0.2rem 0.5rem;
border-radius: 3px;
font-size: 0.95rem;
color: var(--burgundy);
}
.article-content pre {
background: var(--charcoal);
color: var(--warm-gold);
padding: 1.5rem;
border-radius: 4px;
overflow-x: auto;
margin: 2rem 0;
border-left: 4px solid var(--rich-gold);
}
.article-content pre code {
background: none;
color: var(--warm-gold);
padding: 0;
}
.article-content blockquote {
border-left: 4px solid var(--rich-gold);
padding-left: 2rem;
margin: 2rem 0;
font-style: italic;
color: var(--burgundy);
background: linear-gradient(90deg, rgba(212, 175, 55, 0.05), transparent);
padding: 1.5rem 2rem;
}
/* Inline images */
.article-content img {
max-width: 100%;
height: auto;
margin: 2rem 0;
border: 2px solid var(--rich-gold);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
/* Decorative divider */
.deco-divider {
display: flex;
align-items: center;
justify-content: center;
margin: 3rem 0;
gap: 1rem;
}
.deco-divider::before,
.deco-divider::after {
content: '';
width: 100px;
height: 2px;
background: linear-gradient(90deg, transparent, var(--rich-gold), transparent);
}
.deco-divider .diamond {
width: 12px;
height: 12px;
background: var(--rich-gold);
transform: rotate(45deg);
}
/* Footer */
footer {
text-align: center;
padding: 4rem 0;
color: var(--silver);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.9rem;
letter-spacing: 0.15em;
text-transform: uppercase;
margin-top: 4rem;
}
.footer-ornament {
width: 200px;
height: 2px;
background: linear-gradient(90deg, transparent, var(--rich-gold), transparent);
margin: 0 auto 1.5rem;
position: relative;
}
.footer-ornament::before {
content: '';
position: absolute;
width: 10px;
height: 10px;
background: var(--rich-gold);
transform: rotate(45deg);
top: -4px;
left: 50%;
margin-left: -5px;
}
/* Responsive */
@media (max-width: 768px) {
.article-header,
.article-content {
padding: 2rem;
}
.article-title {
font-size: 2.5rem;
}
.article-number {
font-size: 3rem;
}
.featured-image {
height: 300px;
}
.image-placeholder {
font-size: 4rem;
}
.article-container {
padding: 0 1rem;
margin: 2rem auto;
}
.nav-content {
padding: 0 1rem;
}
.nav-logo {
font-size: 1.2rem;
}
}
</style>
</head>
<body>
<!-- Navigation -->
<nav>
<div class="nav-content">
<a href="#" class="nav-logo">Steam Controller Driver</a>
<a href="https://steen.run/blog" class="nav-back">Back to Projects</a>
</div>
</nav>
<!-- Article Container -->
<div class="article-container">
<!-- Article Header -->
<header class="article-header">
<div class="article-number">04</div>
<div class="article-meta">
<span class="article-category">Hardware</span>
<span>November 2025</span>
</div>
<h1 class="article-title">Steam Controller Driver for Actual Motors</h1>
<p class="article-subtitle">
lorem ipsum
</p>
<div class="tech-stack">
<div class="tech-item">React</div>
<div class="tech-item">Node.js</div>
<div class="tech-item">WebSocket</div>
<div class="tech-item">MongoDB</div>
<div class="tech-item">Redis</div>
</div>
</header>
<!-- Featured Image -->
<div class="featured-image">
<div class="image-placeholder"></div>
</div>
<!-- Article Content -->
<article class="article-content">
<h2>Backstory</h2>
<p>
I was cleaning out a closet and I discovered an old Steam Controller I bought about a decade ago. To my surprise, it still turned on. Way to go, Duracell. I remembered it being a pretty niche thing that had very little adoption and was quickly deprecated by Valve. Boy was I wrong. I turned it on and immediately remembered how amazing the haptic feedback was on the touchpads. How could I forget the grip inputs? Even a gyroscope! The design and hardware were top notch, but adoption was the problem.
</p>
<p>
The haptic feedback touchpads made Games like Civ and XCOM playable on the couch. But I had my eyes on something much bigger target that could use a controller with fourteen buttons, six directional axes, two analog triggers, and a gryoscope: Flight Simulator. With traditional controllers, you had to have a plethora of different button configurations to make it work. Joysticks make it hard to sense and control your aircraft. Touchpads allow for refined movement with feedback to help you ramp up and down with ease.
</p>
<div class="deco-divider">
<div class="diamond"></div>
</div>
<h2>Flight Simulator</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum eget tortor massa. Sed sed nulla vitae enim accumsan pulvinar fermentum nec nunc. Phasellus scelerisque sem vel massa tempus, id lobortis arcu mattis. Donec sed lectus cursus libero auctor facilisis. Donec mattis luctus congue. Maecenas et nunc odio. Sed eu diam lectus. Phasellus sit amet mi vitae lacus rhoncus elementum. Nullam dictum justo sed dignissim placerat. Pellentesque porta velit vel luctus efficitur.
</p>
<h3>WebSocket Server</h3>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum eget tortor massa. Sed sed nulla vitae enim accumsan pulvinar fermentum nec nunc. Phasellus scelerisque sem vel massa tempus, id lobortis arcu mattis. Donec sed lectus cursus libero auctor facilisis. Donec mattis luctus congue. Maecenas et nunc odio. Sed eu diam lectus. Phasellus sit amet mi vitae lacus rhoncus elementum. Nullam dictum justo sed dignissim placerat. Pellentesque porta velit vel luctus efficitur.
</p>
<ul>
<li>Handles client connections and disconnections gracefully</li>
<li>Broadcasts operations to all connected clients in a room</li>
<li>Implements heartbeat mechanisms to detect stale connections</li>
<li>Scales horizontally using Redis pub/sub for multi-server deployments</li>
</ul>
<div class="deco-divider">
<div class="diamond"></div>
</div>
<h2>Physical Applications</h2>
<h3>Parsing the Byte Stream</h3>
<p>
If I want to make a driver for this controller, I need to see what the output is. Controllers are just like a keyboard: character devices. When you type an "a" character into your keyboard, the keyboard sends data for an "a" key to the computer. When you press an "a" button on your controller, it does just about the same thing. It sends data, which in this case is 41 in hex or 01000010 in binary, to the computer and it is handled by the OS, which monitors for any special inputs, before sending it to the application you're using. This happens so quickly, we don't even think about it. It's a little more complicated than that, but I'll explain later. What I need to do is reverse engineer what data the inputs on my controller output and write a driver to turn those inputs into actions.
</p>
<p>
Knowing my controller is a character device, I can "read" it just like a file, but I have to jump through a couple hoops first. I don't know what that actual file is yet. I'm going to have to check around. When you plug a device into a computer running an OS, it has to decide what to do with it. Devices that already have their firmware configured will tell the OS what to do with it, usually through the Human Interface Device (HID) standard. So I need to ask the OS where it put the file.
</p>
<pre><code>$ lsusb
Bus 003 Device 022: ID 28de:1102 Valve Software Wired Controller</code></pre>
<p>
This gives us the Bus number, Device number, and both the Device ID and Vendor ID. The bus and device number are abstractions for userspace created by udev. In this instance they seem very redundant and abstract away from what I'm trying to accomplish. What I am trying to do is take the raw input from the device at a kernel level, so I need to ask udev where it's getting this device from.
<pre><code>$ udevadm info -q path -n /dev/bus/usb/003/022
/sys/devices/pci0000:00/0000:00:14.0/usb3/3-7</pre></code>
This shows us where the kernel has put the device. We can go into this series of directories and find it. Problem solved! Almost.
</p>
<p>
In this case, with the steam controller, it actually has 3 subdevices, each with their own hidraw file. As it turns out, the controller can also be used in place of a mouse and keyboard. One could poke around the files some more to differentiate them, but I found a better solution.
<pre><code>sudo usbhid-dump -m 28de:1102 -ed > descriptor.txt
grep -A 10 "DESCRIPTOR" descriptor.txt | grep -v "DESCRIPTOR" | grep -v "STREAM" | tr -d ' \n' > descriptor_hex.txt
rexx rd.rex -d --hex "$(cat descriptor_hex.txt)"</pre></code>
<pre><code>=== /dev/hidraw6 ===
Name: Valve Software Wired Controller
Vendor:Product: 0x28de:0x1102
Bus type: 3
Physical: usb-0000:00:14.0-7/input0
Descriptor size: 65 bytes
Descriptor (first 32 bytes): 05 01 09 06 95 01 A1 01 05 07 19 E0 29 E7 15 00 25 01 75 01 95 08 81 02 95 01 75 08 81 01 95 05
Type hints: [KEYBOARD]
=== /dev/hidraw7 ===
Name: Valve Software Wired Controller
Vendor:Product: 0x28de:0x1102
Bus type: 3
Physical: usb-0000:00:14.0-7/input1
Descriptor size: 52 bytes
Descriptor (first 32 bytes): 05 01 09 02 A1 01 09 01 A1 00 05 09 19 01 29 05 15 00 25 01 95 05 75 01 81 02 95 01 75 03 81 01
Type hints: [MOUSE]
=== /dev/hidraw8 ===
Name: Valve Software Wired Controller
Vendor:Product: 0x28de:0x1102
Bus type: 3
Physical: usb-0000:00:14.0-7/input2
Descriptor size: 33 bytes
Descriptor (first 32 bytes): 06 00 FF 09 01 A1 01 15 00 26 FF 00 75 08 95 40 09 01 81 02 95 40 09 01 91 02 95 40 09 01 B1 02 </pre></code>
</p>
<p>
I found a tool called <i>RDD! HID Report Descriptor Decoder</i> on github by <a href="https://github.com/abend0c1/hidrdd">abend0c1</a>. It takes the data from usbhid-dump and performs lookups on all the devices given. It's a very comprehensive tool that gave much more output than I listed.
</p>
<p>
</p>
<p>
</p>
</article>
</div>
<!-- Footer -->
<footer>
<div class="footer-ornament"></div>
<p>Crafted with Precision & Elegance © 2025</p>
</footer>
</body>
</html>