Reproducible pipeline for investigating bedroom CO₂ levels in a single-family home, pulling sensor data from Home Assistant and generating a self-contained HTML report.
See CO2_PROJECT_PLAN.md for the full investigation context and findings.
# 1. Install Python deps (one time)
python3 -m pip install -r requirements.txt --break-system-packages
# 2. Configure HA credentials
cp .env.example .env
# Edit .env — paste your HA long-lived access token
# 3. Pull data from Home Assistant
./pull-data.sh
# → writes data/raw/history-YYYYMMDD-HHMMSS.json
# 4. Analyze and build the report
python3 analyze.py
python3 build_report.py
# → writes report/co2_report.html
# 5. Serve locally (accessible from phone on same WiFi)
./serve.shFor a dashboard that refreshes itself on a timer:
# One-off: pull rolling 7-day window, re-analyze, rebuild report
./live.shTo run it automatically every 5 minutes on macOS:
# Install the LaunchAgent (one time)
cp com.samgutentag.co2.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.samgutentag.co2.plist
# Check it ran
tail data/processed/live.log
# To stop:
launchctl unload ~/Library/LaunchAgents/com.samgutentag.co2.plistThe generated HTML includes a meta refresh so an open browser tab will reload
every 5 minutes to pick up the new build. The top of the report shows a "Now"
strip with each room's current ppm, an air-quality band (low / med / high), and
a 30-minute trend arrow.
For a Raspberry Pi or other Linux host, swap the LaunchAgent for a systemd
timer pointing at the same live.sh.
pull-data.sh reads START and END from the environment (ISO 8601):
START=2026-01-15T00:00:00-08:00 \
END=2026-01-21T23:59:59-08:00 \
./pull-data.shFor a vacation-week pull (only environmental sensors, skips motion/AC/appliances):
MODE=vacation ./pull-data.shco2-investigation/
├── README.md
├── CO2_PROJECT_PLAN.md # original spec + findings
├── findings.md # standalone writeup of conclusions
├── pull-data.sh # HA REST API export
├── .env.example # HA_TOKEN, HA_URL template
├── .env # (gitignored) your real credentials
├── requirements.txt
├── data/
│ ├── raw/ # JSON exports, timestamped (gitignored)
│ └── processed/ # pickled DataFrames (gitignored)
├── analyze.py # parse + derive metrics
├── build_report.py # render HTML report
├── report/
│ ├── template.html # Jinja2 template
│ └── co2_report.html # generated (gitignored)
└── serve.sh # local hosting one-liner
When the office CO₂ and main-room CO₂ sensors come online:
- Add the new entity IDs to the
ENTITIESlist inpull-data.sh(and bump theEXPECTEDcount). - Add aliases for them in the
ALIASdict inanalyze.py. - Re-run
./pull-data.sh && python3 analyze.py && python3 build_report.py.
The pipeline is built to make this painless — no other code should need to change.
When the house is empty for a few days, the CO₂ decay curve gives a measured natural air-exchange rate (ACH) for the envelope. That single number decides whether mechanical ventilation (ERV/HRV) is necessary in the HVAC renovation.
# Pull hourly long-term statistics for the empty-house window
START=2026-03-29T00:00:00-07:00 \
END=2026-04-03T23:59:59-07:00 \
OUT=data/raw/vacation-2026-03-29.json \
python3 pull-vacation.py
# Fit exponential decay per room, print ACH + equilibrium summary
python3 analyze_vacation.py
# Rebuild the report — picks up data/processed/vacation.pkl automatically
# and adds a §07 Vacation Week section. (The LaunchAgent does this every 5 min.)
python3 build_report.pypull-vacation.py uses the WebSocket recorder/statistics_during_period API
because the REST /api/history/period endpoint only reads from the short-term
recorder (default 10-day retention). Long-term hourly aggregates are kept
indefinitely for any sensor with state_class: measurement, which is what
the SwitchBot CO₂ / temperature / humidity sensors all use.
To capture future vacation weeks at full 5-minute resolution instead of falling
back to hourly aggregates, increase the recorder retention in HA's
configuration.yaml:
recorder:
purge_keep_days: 90 # keep full state history for 90 days (default is 10)A 90-day window keeps the recorder DB under ~1 GB for a typical sensor
inventory and covers seasonal experiments (summer vs winter behavior). For
year-over-year comparisons, set to 365 and consider moving the recorder to
PostgreSQL or pairing with an InfluxDB integration.
Restart HA after the change. Existing older data won't come back, but everything captured from now on persists at full resolution.
- The HA history API returns the last known state at
start_timeas the first event for each entity, even if it predates the window. Don't be surprised by opening events with timestamps earlier thanSTART. minimal_response&no_attributesis set on the API call. Cuts response size ~3× without losing anything we need.- Plotly is loaded from
cdn.jsdelivr.net/npm/plotly.js-dist-min@2.35.2/plotly.min.jsin the report. Thecdn.plot.lyhost is unreliable for specific minor versions.