diff --git a/.gitignore b/.gitignore index 7361c94..48e92f3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ _restore_backup_*/ *.gbr.bak *.drl.bak +# Firmware credentials - never commit live keys +firmware/esp-sensair.yaml +firmware/secrets.yaml +**/secrets.yaml + # OS / editor noise .DS_Store *~ diff --git a/README.md b/README.md index 218dd68..d1b5208 100644 --- a/README.md +++ b/README.md @@ -23,20 +23,21 @@ running. ``` +5 V | - +-------------------------+---------------------------+ - | | | - | +----------v----------+ | - | | ESP32-C3 module |--- 3.3 V out --+| - | | (onboard 3V3 LDO) | || - +--------------+ | || - | UART 9600 +----------+----------+ || - | | || + +-------------------------+ + | | + | +----------v----------+ + | | ESP32-C3 module |--- 3.3 V out --+ + | | (onboard 3V3 LDO) | | + +--------------+ | | + | UART 9600 +----------+----------+ | + | | | +-----v-------+ +------v-------+ v -| Sensirion S8| | BME280 |<--- I2C (SDA/SCL) --+ +| Sensirion S8| | BME280 |<--- I2C (SDA/SCL) -+ | NDIR CO2 | | T / RH / P | +-------------+ +--------------+ -SW1 (calibration button) -- RC debounce (1 k / 100 n) -- ESP32-C3 GPIO +SW1 (calibration button) -- RC (1 k / 100 n) -- Sensirion S8 bCal_in (pin 8) + 5 V enters at the ESP32-C3 devkit's USB-C connector and is passed to the S8 via the 5 V net. ``` ## Connections @@ -52,6 +53,10 @@ SW1 (calibration button) -- RC debounce (1 k / 100 n) -- ESP32-C3 GPIO | `/SCL` | pad 8 | — | pad 3 | | `bCal_in` | — | pad 8 (`bCal_in`) | — | +The calibration button SW1 is wired to the **S8's own `bCal_in` input**, not to the MCU: pulling +`bCal_in` low starts the sensor's background calibration. C1 (100 nF) and R1 (1 kΩ to GND) form the +debounce/RC network on that line. + Sensirion S8 pins `Alarm_OC`, `PWM_1kHz`, `DVCC_out` and `UART_R/T` are left unconnected. ## Bill of materials @@ -105,28 +110,53 @@ All parts are through-hole or plug-in modules on pin headers; hand soldered with The Sensirion S8 requires a 5 V supply (its NDIR heater draws current pulses of up to ~150 mA) and about 30 s of preheat time before it reports valid CO₂ values. +## Grounding and copper + +Both copper layers carry a **GND copper pour** (one zone on `F.Cu` + `B.Cu`, filled, thermal-relief +connection to pads with 0.5 mm gap, 0.5 mm thermal bridge). Four rule-area keep-outs (pads and +footprints excluded, copper pour allowed) mark the module areas, including the ESP32-C3 antenna +region. + ## Firmware -ESPHome, see [`firmware/`](firmware/) for the config and the Home Assistant wiring. -S8 UART: 9600 baud, 8N1. BME280 I²C: standard address `0x76` (or `0x77` depending on the breakout). +ESPHome on `esp32-c3-devkitm-1`, framework **esp-idf**. Config template: +[`firmware/esp-sensair.yaml.example`](firmware/esp-sensair.yaml.example), secrets template: +[`firmware/secrets.yaml.example`](firmware/secrets.yaml.example). The flashed config and +`secrets.yaml` are git-ignored — **never commit live keys.** + +| Item | Value | +|---|---| +| I²C | `SDA = GPIO7`, `SCL = GPIO8`, bus scan enabled | +| BME280 | I²C address `0x76`, temperature / pressure / humidity, 60 s interval | +| Sensirion S8 | UART `TX = GPIO21`, `RX = GPIO20`, 9600 baud, 60 s interval | +| Home Assistant | native ESPHome API with noise encryption, OTA updates | +| Network | static IP, `fast_connect`, fallback hotspot + captive portal | ## Known issues and lessons learned (v1 → v2) -1. **No copper pours.** Neither layer has a ground plane. v2: solid GND pour on the bottom layer - with stitching vias around the ESP32-C3 module. -2. **No mounting holes.** The board is held by its connectors only. v2: four M2.5 holes. -3. **No I²C pull-ups populated.** The design relies on the pull-ups of the BME280 breakout. +1. **Power comes in through the ESP32-C3 devkit's USB-C 5 V pin.** The Sensirion S8 heater draws + current pulses of up to ~150 mA, and that current travels through the module's USB connector, its + 5 V rail and its onboard regulator path. v2: a **dedicated 5 V power input** (2.54 mm header, + screw terminal or DC barrel jack) wired straight to the 5 V net, independent of the module's + USB-C, which then stays a programming/debug connection. Add to that: 220–470 µF bulk plus 100 nF + at the S8 supply pins, reverse-polarity protection (series Schottky or P-MOSFET), a jumper to + back-feed 5 V from the module's USB for programming-only setups, and a power-good LED. Before + changing anything: measure the ripple and droop on the 5 V rail with an oscilloscope while the S8 + heater fires, so v2 fixes a quantified problem. +2. **Single 100 nF decoupling capacitor** for the whole board. v2: 100 nF per supply pin, bulk + capacitance at each sensor, and supply stitching vias into the ground pour. +3. **No mounting holes.** The board is held by its connectors only. v2: four M2.5 holes. +4. **No I²C pull-ups populated.** The design relies on the pull-ups of the BME280 breakout. v2: add 4.7 kΩ pull-up footprints on `SDA`/`SCL`. -4. **Single 100 nF decoupling capacitor** for the whole board. v2: 100 nF per supply pin plus a - 10–100 µF bulk capacitor close to the S8 heater supply, which is the actual current-pulse source. 5. **Schematic is not annotated** (references `U`, `U`, `U`), so KiCad BOM export is unusable. v2: annotate, set MPNs, and export the BOM from the schematic. 6. **Leaded HASL** is not RoHS-compliant. v2: HASL lead-free, and ENIG for any impedance-controlled design. 7. **Project name contains `+`.** It propagates into the Gerber file names (`esp+sensair-Front.gbr`), which some fabricator upload portals and drill parsers reject. v2: `esp-sensair`. -8. **Antenna keep-out** for the ESP32-C3 module should be checked explicitly against the module - datasheet (no copper, no silkscreen, no vias under the antenna area). +8. **Antenna keep-out** for the ESP32-C3 module is defined as a rule area (pads and footprints + excluded, copper pour allowed). v2: verify it against the module datasheet's antenna keep-out + figure, and check the pour's clearance to the antenna region specifically. ## Repository layout @@ -141,7 +171,7 @@ S8 UART: 9600 baud, 8N1. BME280 I²C: standard address `0x76` (or `0x77` dependi ├── gerber/ Gerber X2, Excellon drills, job file, ready-to-upload zip ├── bom/ bill of materials (CSV) ├── docs/ schematic PDF, layer plots, 3D render -├── firmware/ ESPHome configuration +├── firmware/ ESPHome config templates, secrets templates, notes ├── photos/ photographs of the assembled board ├── LICENSE CERN-OHL-S-2.0 (hardware) └── LICENSE.MIT MIT (firmware config, BOM, docs) diff --git a/firmware/README.md b/firmware/README.md index 5f1709d..69bdded 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -1,31 +1,59 @@ # Firmware -ESPHome configuration for the ESP32-C3 CO₂ & environment sensor board. +ESPHome firmware for the ESP32-C3 CO₂ & environment sensor board. + +## Files + +| File | In git | Purpose | +|---|---|---| +| `esp-sensair.yaml.example` | yes | configuration template, all credentials as `!secret` references | +| `secrets.yaml.example` | yes | secrets template with placeholders | +| `esp-sensair.yaml` | **no** (git-ignored) | your flashed configuration | +| `secrets.yaml` | **no** (git-ignored) | your Wi-Fi, API, OTA and hotspot credentials | + +Setup: + +```bash +cp firmware/esp-sensair.yaml.example firmware/esp-sensair.yaml +cp firmware/secrets.yaml.example firmware/secrets.yaml +# fill in firmware/secrets.yaml +esphome run firmware/esp-sensair.yaml +``` + +Generate the Home Assistant API encryption key with: + +```bash +python3 -c "import base64,os; print(base64.b64encode(os.urandom(32)).decode())" +``` ## Target | Item | Value | |---|---| -| Chip | ESP32-C3 (RISC-V, single core, Wi-Fi + BLE) | -| Framework | ESPHome (`esp32` platform, `framework: esp-idf` or `arduino`) | -| Board | this project's board, flashed via the module's UART/USB boot pins | +| Chip | ESP32-C3 (RISC-V, single core, Wi-Fi) | +| ESPHome board | `esp32-c3-devkitm-1` | +| Framework | ESP-IDF | +| Flashing | USB-C on the devkit module (ESPHome OTA after first flash) | ## Peripherals -| Peripheral | Bus | Notes | -|---|---|---| -| Sensirion S8 (NDIR CO₂) | UART, 9600 baud 8N1 | needs 5 V supply, ~30 s preheat; `bCal_in` starts a manual background calibration | -| BME280 (T / RH / P) | I²C, address `0x76` (some breakouts `0x77`) | powered from the module's 3.3 V output; pull-ups come from the breakout | -| SW1 | GPIO, active low with 1 kΩ / 100 nF RC debounce | calibration button | +| Peripheral | Bus | Pins | Notes | +|---|---|---|---| +| Sensirion S8 (NDIR CO₂) | UART, 9600 baud 8N1 | `TX = GPIO21`, `RX = GPIO20` | 5 V supply, ~30 s preheat; the S8's own `bCal_in` pin carries the on-board calibration button | +| BME280 (T / RH / P) | I²C, address `0x76` | `SDA = GPIO7`, `SCL = GPIO8` | powered from the module's 3.3 V output, pull-ups on the breakout | +| SW1 | — | not on the MCU | wired to the S8 `bCal_in` input with a 1 kΩ / 100 nF RC network | -## Add here - -- `esp-sensair.yaml` — the ESPHome configuration actually flashed to the board -- `secrets.yaml` — **do not commit** (Wi-Fi credentials, Home Assistant token); keep - `secrets.yaml.example` with placeholders -- flashing notes: `esptool` / `esphome flash`, boot-mode entry on the module +Update interval for both sensors: 60 s. ## Home Assistant -The device is exposed as native ESPHome entities: `co2`, `temperature`, `relative_humidity`, -`pressure`, plus the calibration button as a sensor event. +Exposed as native ESPHome entities: CO₂, temperature, relative humidity, pressure. API connection is +encrypted (noise protocol), OTA updates enabled, static IP with a fallback hotspot and captive portal +if the Wi-Fi connection fails. + +## Ideas for v2 + +- Trigger the S8 background calibration from Home Assistant (`senseair` platform actions) in addition + to the hardware button, and log the calibration events. +- Automatic baseline calibration window tuned to the room's nightly CO₂ minimum. +- Report Wi-Fi signal strength and device uptime as sensors for diagnostics. diff --git a/firmware/esp-sensair.yaml.example b/firmware/esp-sensair.yaml.example new file mode 100644 index 0000000..a19b321 --- /dev/null +++ b/firmware/esp-sensair.yaml.example @@ -0,0 +1,72 @@ +# ESPHome configuration for the ESP32-C3 CO2 & environment sensor board. +# +# This is the SHARED TEMPLATE. Copy it to esp-sensair.yaml (git-ignored) and keep +# all credentials in secrets.yaml (git-ignored). Never commit real keys. +# +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Hannes Gehrmann + +esphome: + name: esp32c3-bme280 + friendly_name: esp32c3-bme280 + +esp32: + board: esp32-c3-devkitm-1 + framework: + type: esp-idf + +logger: + +api: + encryption: + key: !secret api_encryption_key + +ota: + - platform: esphome + password: !secret ota_password + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + fast_connect: true + + manual_ip: + static_ip: !secret static_ip + gateway: !secret gateway + subnet: !secret subnet + dns1: !secret dns1 + + # Fallback hotspot if the Wi-Fi connection fails + ap: + ssid: "Esp32C3-Bme280 Fallback Hotspot" + password: !secret ap_password + +captive_portal: + +# BME280 on I2C - SDA on GPIO7, SCL on GPIO8 (board nets /SDA, /SCL) +i2c: + sda: GPIO7 + scl: GPIO8 + scan: true + +# Sensirion S8 on UART - 9600 baud, 8N1 (board nets /TX, /RX) +uart: + tx_pin: GPIO21 # ESP32-C3 TX -> S8 RX + rx_pin: GPIO20 # ESP32-C3 RX <- S8 TX + baud_rate: 9600 + +sensor: + - platform: bme280_i2c + address: 0x76 + temperature: + name: "BME280 Temperature" + pressure: + name: "BME280 Pressure" + humidity: + name: "BME280 Humidity" + update_interval: 60s + + - platform: senseair + co2: + name: "Senseair S8 CO2" + update_interval: 60s diff --git a/firmware/secrets.yaml.example b/firmware/secrets.yaml.example new file mode 100644 index 0000000..617a964 --- /dev/null +++ b/firmware/secrets.yaml.example @@ -0,0 +1,14 @@ +# Copy to firmware/secrets.yaml (git-ignored) and fill in your own values. +# +# Generate a Home Assistant API encryption key with: +# python3 -c "import base64,os; print(base64.b64encode(os.urandom(32)).decode())" + +wifi_ssid: YourNetworkSSID +wifi_password: YourNetworkPassword +api_encryption_key: REPLACE_WITH_GENERATED_32_BYTE_BASE64_KEY +ota_password: REPLACE_WITH_AN_OTA_PASSWORD +ap_password: REPLACE_WITH_A_FALLBACK_HOTSPOT_PASSWORD +static_ip: 192.168.1.50 +gateway: 192.168.1.1 +subnet: 255.255.255.0 +dns1: 192.168.1.1