Set-up to monitor my brew fermentation (over a ~10 day period) to watch for co2 levels to drop to indicate the brew is done.
Also have a Rowi (API enabled plug) to turn on my heater belt if the temp drops low.
Currently a WIP - will improve and add some 3d print files for the housing ( fits on top of the airlock).
The CCS811 sensor (Mode 1) produces new eCO2/tVOC readings every 1 second. fermmon.py polls every 10 seconds, accumulates samples, and writes one averaged reading to SQLite every 5 minutes. This gives ~288 readings/day (~2900 for a 10-day fermentation), which is sufficient to see the trend while keeping storage and chart load manageable.
| Setting | Value | Purpose |
|---|---|---|
| Poll interval | 10 s | Check sensor, read when data available |
| Write interval | 5 min | DB write and temp/relay logic |
| Samples per write | ~30 | Rolling average for noise reduction |
API push: Set API_URL (default https://localhost:443) to have fermmon POST readings to the web API instead of local SQLite. On API failure, it falls back to local DB. Set API_URL= (empty) to use local SQLite only.
| Mode | Interval | Use |
|---|---|---|
| 0 | Idle | No measurements, low power |
| 1 | 1 s | Constant power (fermmon uses this) |
| 2 | 10 s | Pulse heating, lower power |
| 3 | 60 s | Low power pulse |
| 4 | 250 ms | Raw data only, factory test |
Software reset: On startup, the qwiic library writes 0x11,0xE5,0x72,0x8A to register 0xFF. This resets the device into Bootloader; APP_START then switches to Application mode. The sensor is left in a known state before setting Mode 1.
Mode change rules (when changing drive mode at runtime):
- Lower sample rate (e.g. Mode 1 → Mode 3): Put in Mode 0 (Idle) for at least 10 minutes before enabling the new mode.
- Higher sample rate (e.g. Mode 3 → Mode 1): No wait required.
fermmon starts in Mode 1 and does not change mode at runtime, so no idle transition is needed.
Burn-in: 48-hour initial burn-in recommended for new sensors; 20 min warm-up when resuming use.
Environment: The CCS811 is sensitive to humidity and temperature. In warm, moist spaces (e.g. laundry room with washer/dryer running), readings can spike or become unreliable. Good ventilation helps: run an extractor fan and/or a secondary fan to reduce humidity and heat buildup. If readings go haywire, moving the sensor to fresh air for 20–30 minutes to reset the baseline often helps.
- pi-zero 2w
- ccs811 - air quality sensor (sparkfun)
- DS18b20 - Temperature Sensor Probe - Stainless steel
- rowi 2
- Mangrove Jack starter brew kit
- Thermowell 300mm - cut to suit
# Uncomment some or all of these to enable the optional hardware interfaces
dtparam=i2c_arm=on
dtparam=i2s=on
dtparam=spi=on
# Enable I2C clock stretching
dtparam=i2c_arm_baudrate=10000
...
dtoverlay=w1-gpio
enable_uart=1
- git
- python3-pip
- php (>=7.4) php-sqlite3 php-mbstring
- apache2 (or nginx + php-fpm) with mod_rewrite
- composer
- pip3 install sparkfun-qwiic-i2c # see also https://github.com/sparkfun/Qwiic_I2C_Py
Note: see also https://github.com/sparkfun/Qwiic_CCS811_Py but there is a pull request open for this
cd web && composer installRun the PHP app locally with the built-in server:
cd /path/to/fermmon
php -S localhost:8080 -t web/public web/public/router.phpThen open http://localhost:8080. The router forwards requests to index.php for clean URLs.
Browser notifications and PWA features (service worker, install prompt) require a secure context (HTTPS or localhost). On a LAN, use a self-signed certificate:
1. Generate a self-signed cert (valid 10 years):
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/ssl/private/fermmon.key \
-out /etc/ssl/certs/fermmon.crt \
-subj "/CN=fermmon.local" \
-addext "basicConstraints=critical,CA:FALSE" \
-addext "subjectAltName=DNS:fermmon.local,DNS:asteroids,IP:192.168.0.10"Replace 192.168.0.10 with your Pi's IP. Add other hostnames to the subjectAltName list if needed. The basicConstraints=CA:FALSE avoids Apache's "CA certificate" warning.
2. Add HTTPS VirtualHosts to your Apache config (e.g. in apache-fermmon.conf or a separate SSL config). Replace the existing *:80 VirtualHost with a redirect, and add the HTTPS host:
# Redirect HTTP to HTTPS (preserves host: fermmon.local or IP)
<VirtualHost *:80>
ServerName fermmon.local
RewriteEngine On
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>
<VirtualHost *:443>
ServerName fermmon.local
DocumentRoot /home/ubuntu/fermmon/web/public
LogLevel warn
SSLEngine on
SSLCertificateFile /etc/ssl/certs/fermmon.crt
SSLCertificateKeyFile /etc/ssl/private/fermmon.key
<Directory /home/ubuntu/fermmon/web/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/fermmon-ssl-error.log
CustomLog ${APACHE_LOG_DIR}/fermmon-ssl-access.log combined
</VirtualHost>3. Enable SSL and restart:
sudo a2enmod ssl
sudo systemctl restart apache24. Access via https://fermmon.local or https://192.168.0.10. Accept the browser's certificate warning once per device; after that, notifications and PWA work.
Alternative (no warnings): Use mkcert to create locally-trusted certs. Install the mkcert CA on each device that will use the app.
API and integration tests verify the config and hide-outliers flow.
cd web && composer install
composer testTests use an isolated SQLite database (data/test/). They live in web/tests/.
The Brews page (/brews) manages brew versions: add new versions (with optional description) and edit existing brew details.
The Log page (/log) lets you add brew log entries (e.g. dry hop at day 2). Entries are annotated on the CO2 chart.
The Control page (/control) manages operational settings:
| Feature | Description |
|---|---|
| Start/Stop recording | Pause data writes while fermmon keeps running (temp/relay still active). Use when changing batches. |
| Temperature alerts | Target temp and warning threshold. |
| Dashboard refresh | Poll intervals for summary and charts. |
| Timing | Sample and write intervals (advanced). |
fermmon reads the config table each cycle, so changes take effect without restart.
POST /api/readings accepts a JSON body for a single reading (single-fermenter mode):
| Field | Required | Description |
|---|---|---|
| co2 | ✓ | CO2 (ppm) |
| tvoc | ✓ | tVOC (ppb) |
| temp | ✓ | Internal temp (°C) |
| date_time | Defaults to now | |
| version | Defaults to current version | |
| rtemp, rhumi, relay | Optional |
Example: {"co2": 1200, "tvoc": 450, "temp": 19.2}
Planned: Security (device ID + token), multi-fermenter support, and securing Control when the app is online.
sudo cp fermmon.service /etc/systemd/system/
sudo systemctl enable fermmon.service # start on boot
sudo systemctl start fermmon.service # start nowThe old http.service (Python HTTP server) is replaced by Apache/nginx. Point DocumentRoot to web/public/.
Apache (single-site setup): See apache-fermmon.conf for step-by-step instructions. After install, run sudo ./scripts/setup-web-permissions.sh so Apache can write to the DB (Control page, config, etc.).
| Action | Command |
|---|---|
| Start | sudo systemctl start fermmon.service |
| Stop | sudo systemctl stop fermmon.service |
| Restart | sudo systemctl restart fermmon.service |
| Status | sudo systemctl status fermmon.service |
| View logs | journalctl -u fermmon -f |
When you begin a new fermentation, set the current version so new readings are tagged correctly.
Via Brews page (recommended): Open /brews, add the new version (number, brew name, optional URL, optional description). Pause recording on Control before adding if changing batches.
Via CLI:
cd /home/ubuntu/fermmon
python scripts/set-current-version.py 15 "My New Brew Name" https://optional-urlAccepts version as 15 or v15. The version is added to the DB and marked current. No restart needed.
Keep the DB on the Pi; back up to USB or NFS. SQLite on network/USB filesystems can be unreliable, so avoid putting the live DB there.
Backup (uses sqlite3 .backup – safe while fermmon is running):
./scripts/backup-db.sh /media/usb/fermmon-backups # USB mount
./scripts/backup-db.sh /mnt/nfs/fermmon-backups # NFS mountThe script keeps the last 7 backups. Add to crontab for daily runs:
0 2 * * * /home/ubuntu/fermmon/scripts/backup-db.sh /media/usb/fermmon-backups
Restore: Stop fermmon, copy a backup over data/fermmon.db, start fermmon.
USB mount: Add to /etc/fstab so the drive mounts at boot (use UUID for reliability). Ensure the mount point exists and is writable.
Imports fermmon.csv, fermmon_v4.csv, fermmon_v5.csv, etc., and version.csv into SQLite. Version IDs stored without "v" prefix (14 not v14).
cd /home/ubuntu/fermmon && php data/migrate-csv.phpThen restart fermmon.service. CSV files can be removed after verifying.
The R script (fermmon.r) is no longer needed; charts are now rendered client-side with Chart.js.
The web dashboard shows two Chart.js time-series:
- CO2 and tVOC over Time – Primary fermentation indicators. When CO2 drops toward the "normal air" reference (1000 ppm for a small indoor room), fermentation is likely done.
- Temperature and Humidity over Time – Internal/external temp, humidity, and heat belt status.
The X-axis shows Day 0, Day 1, Day 2... from the first reading of the selected brew, with the start date in the axis title. A "Hide outliers" toggle excludes sensor spikes (CO2/tVOC > 6000) so the chart scales to the baseline.
Colours follow common IAQ and environmental conventions:
| Metric | Colour | Rationale |
|---|---|---|
| CO2 | Teal (#0d9488) | Common for air/gas in IAQ displays |
| tVOC | Amber (#d97706) | Organic/VOC, complementary to CO2 |
| Int. temp | Red (#dc2626) | Thermometer convention (warm) |
| Ext. temp | Orange (#ea580c) | Distinct from internal |
| Humidity | Cyan (#0891b2) | Water convention |
| Heat belt | Yellow (#f5c842) | Active/on |
Dashed reference lines show "normal air" (1000 ppm CO2, 200 ppb tVOC) for comparison.
CO2/tVOC sensors can spike to 16k+ ppm/ppb while fermentation typically runs 1–3k. Options:
- Display filter (default): "Hide outliers" toggle excludes readings > 6000. Chart scales to baseline.
- API:
?max_co2=6000&max_tvoc=6000on/api/readings. - SQLite:
php scripts/mark-outliers.phpaddsis_outliercolumn for analysis. - Data collection:
fermmon.pyrecords all readings; the web "Hide outliers" toggle filters display.
- readTemp.py to check if the 1-wire temperature probe is working
- testRowi.py to check that Rowi is working
- check cron ** journalctl -u cron -f
- creck for debugging messages ** journalctl -u fermmon -f
| Doc | Description |
|---|---|
| docs/debugging.md | Debugging tips and log inspection |
| docs/device-registration-plan.md | Device registration, Control auth, multi-fermenter |
| docs/multi-fermenter-plan.md | Multi-fermenter architecture |
| docs/upgrade-plan.md | CSV → SQLite upgrade and deployment |
- https://www.sparkfun.com/sparkfun-environmental-combo-breakout-ens160-bme280-qwiic.html
- https://surplustronics.co.nz/products/7361-temperature-sensor-probe-ds18b20-stainless-steel?srsltid=AfmBOoqpXowka8xFb00-9X7AfwTPzsrlTYhQia4CZAeGhiJS74ml-dqN
- https://www.kiwi-warmer.co.nz/shop/product/826130/rowi-2-smart-plug-with-api/
- https://mangrovejacks.com/collections/starter-brewery/products/traditional-series-blonde-lager-starter-brewery
- https://allthingsfermented.nz/products/ss-brewtech-stainless-steel-weldless-thermowell