v1.10.0: intégration capteur CCS811 (TVOC/eCO2, I2C)

Nouveau capteur de qualité d'air CCS811 sur le bus I2C, calqué sur le
pattern S88 (local-only, pas encore dans le payload de transmission).

- CCS811/get_data.py (lecture live) + write_data.py (timer 10s, self-heal table)
- table data_CCS811 (timestamp, eCO2, TVOC) dans create_db.py
- config CCS811 (bool) + CCS811_address (0x5A/0x5B, défaut 0x5A) dans set_config.py
- service+timer systemd nebuleair-ccs811-data (10s) + ajout boucle d'activation
- admin.html: case d'activation + dropdown adresse I2C
- sensors.html: carte Get Data (TVOC + eCO2)
- database.html + launcher.php: consultation/export/stats data_CCS811
- lib adafruit-circuitpython-ccs811 dans installation_part1.sh
- CCS811/README.md: câblage, adresses, warning clock-stretching I2C sur Pi
- CLAUDE.md + changelog mis à jour

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
PaulVua
2026-06-02 14:27:11 +02:00
parent 4767b145b2
commit 4f3d273981
14 changed files with 455 additions and 9 deletions

83
CCS811/README.md Normal file
View File

@@ -0,0 +1,83 @@
# CCS811 — Capteur qualité d'air (eCO2 / TVOC)
Capteur de gaz **MOX** (oxyde métallique) AMS CCS811. Connecté en **I2C**.
## ⚠ À lire avant de câbler
Le CCS811 **n'est pas** un capteur CO2 NDIR comme le Senseair S88. C'est un capteur
de COV (composés organiques volatils) qui mesure :
- **TVOC** (Total Volatile Organic Compounds) — en **ppb**. C'est la mesure réellement
utile / fiable du capteur, et celle qui nous intéresse ici.
- **eCO2** (CO2 *équivalent*) — en **ppm**, plage 4008192. Valeur *calculée* à partir
du TVOC par un algorithme interne, ce **n'est pas** une mesure directe du CO2. Pour
un vrai CO2, utiliser le S88. On stocke quand même l'eCO2 (gratuit, vient de la même
lecture) mais ne pas le confondre avec une mesure NDIR.
## ⚠ Clock-stretching I2C sur Raspberry Pi
Le CCS811 utilise massivement le **clock-stretching** I2C. Le contrôleur I2C matériel
du Raspberry Pi (BSC) gère **mal** le clock-stretching (bug matériel documenté). Sans
mitigation, les lectures échouent typiquement en `OSError` / `Remote I/O error`.
**Mitigation** : ralentir le bus I2C dans `/boot/firmware/config.txt` :
```
dtparam=i2c_arm_baudrate=10000
```
(10 kHz au lieu de 100 kHz par défaut.) Reboot ensuite. À valider au bench — sur
certains modules/CM4 ça passe à 100 kHz, sur d'autres non.
Vérifier la présence du capteur :
```bash
sudo i2cdetect -y 1 # doit montrer 5a (ou 5b selon la broche ADDR)
```
## Adresse I2C
- **0x5A** : ADDR à GND — défaut des breakouts **Adafruit**. Valeur par défaut du firmware.
- **0x5B** : ADDR à VDD — défaut des breakouts **SparkFun** / modules génériques.
Configurable dans `admin.html` (clé config `CCS811_address`, dropdown 0x5A / 0x5B).
## Câblage I2C
| CCS811 | Raspberry Pi |
|---|---|
| VCC / VIN | 3.3V |
| GND | GND |
| SDA | SDA (GPIO2) |
| SCL | SCL (GPIO3) |
| WAK / nWAKE | GND (réveil permanent ; sinon laisser le module gérer) |
| ADDR | GND → 0x5A, VDD → 0x5B |
⚠ La plupart des breakouts CCS811 sont en **3.3V** logique. Ne pas alimenter en 5V
sans level-shifter sauf si le module embarque son propre régulateur + shifter.
## Burn-in / conditionnement
- **Burn-in initial** : ~48 h de fonctionnement continu avant des valeurs stables (1ère mise en service).
- **Warm-up** à chaque démarrage : ~20 min pour des valeurs fiables. Au démarrage le
capteur renvoie souvent eCO2=400 ppm / TVOC=0 ppb (valeurs de repos).
## Implémentation NebuleAir
- `CCS811/get_data.py` — lecture live (bouton "Get Data" du web). Affiche
`{"eCO2": <ppm>, "TVOC": <ppb>}` ou `{"error": "..."}`.
- `CCS811/write_data.py` — lecture périodique (timer systemd, toutes les 10 s),
écrit dans la table `data_CCS811 (timestamp, eCO2, TVOC)`.
Librairie Python : `adafruit-circuitpython-ccs811` (installée par
`installation_part1.sh`). La table est créée par `sqlite/create_db.py` et
self-healée par `write_data.py` (CREATE TABLE IF NOT EXISTS) — garder les deux
schémas synchro.
Activation : `admin.html` → case "Send VOC sensor data (CCS811)".
### Pistes d'amélioration (non implémentées)
Le CCS811 supporte une compensation température/humidité (`SET_ENV_DATA`). Comme le
boîtier embarque déjà un BME280, on pourrait lui pousser temp/hum à chaque lecture
pour améliorer la précision. Non fait en v1 pour garder le script simple et autonome.

87
CCS811/get_data.py Normal file
View File

@@ -0,0 +1,87 @@
'''
Live read of the AMS CCS811 air-quality sensor (used by the web "Get Data" button).
Prints a JSON object: {"eCO2": <int_ppm>, "TVOC": <int_ppb>} or {"error": "<message>"}.
CCS811 is a MOX gas sensor: it outputs an equivalent CO2 (eCO2, derived from VOCs)
and a Total VOC (TVOC). It is NOT an NDIR CO2 sensor like the S88. TVOC is the
primary measurement of interest here.
I2C, library adafruit-circuitpython-ccs811. Address read from config_table
(key CCS811_address, e.g. "0x5A" Adafruit / "0x5B" SparkFun), default 0x5A.
Usage: /usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/get_data.py [address]
'''
import json
import sqlite3
import sys
import time
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
DEFAULT_ADDRESS = 0x5A
# CCS811 produces a fresh sample every 1 s in drive mode 1. Poll data_ready a few
# times to cover the case where the driver was just (re)initialised.
DATA_READY_RETRIES = 30
DATA_READY_DELAY = 0.2 # seconds
def get_address_from_config():
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT value FROM config_table WHERE key = ?", ("CCS811_address",))
row = cursor.fetchone()
conn.close()
if row and row[0]:
return int(str(row[0]), 16)
except Exception:
pass
return DEFAULT_ADDRESS
def main():
if len(sys.argv) > 1:
try:
address = int(sys.argv[1], 16)
except ValueError:
print(json.dumps({"error": f"invalid address {sys.argv[1]}"}))
return
else:
address = get_address_from_config()
try:
import board
import busio
import adafruit_ccs811
except Exception as e:
print(json.dumps({"error": f"library import failed: {e}"}))
return
try:
i2c = busio.I2C(board.SCL, board.SDA)
ccs811 = adafruit_ccs811.CCS811(i2c, address=address)
except Exception as e:
print(json.dumps({"error": f"cannot init CCS811 at {hex(address)}: {e}"}))
return
try:
ready = False
for _ in range(DATA_READY_RETRIES):
if ccs811.data_ready:
ready = True
break
time.sleep(DATA_READY_DELAY)
if not ready:
print(json.dumps({"error": "CCS811 data not ready (warming up?)"}))
return
eco2 = ccs811.eco2
tvoc = ccs811.tvoc
print(json.dumps({"eCO2": int(eco2), "TVOC": int(tvoc)}))
except Exception as e:
print(json.dumps({"error": f"CCS811 read error: {e}"}))
if __name__ == "__main__":
main()

98
CCS811/write_data.py Normal file
View File

@@ -0,0 +1,98 @@
'''
Script to get air-quality values from the AMS CCS811 sensor and write to database.
/usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/write_data.py
CCS811 is a MOX gas sensor: eCO2 (equivalent CO2 in ppm, derived from VOCs) and
TVOC (Total VOC in ppb). TVOC is the primary measurement of interest.
I2C, library adafruit-circuitpython-ccs811. Address from config_table
(key CCS811_address, e.g. "0x5A" / "0x5B"), default 0x5A.
'''
import sqlite3
import sys
import time
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
DEFAULT_ADDRESS = 0x5A
DATA_READY_RETRIES = 30
DATA_READY_DELAY = 0.2 # seconds
def get_config(cursor, key, default):
cursor.execute("SELECT value FROM config_table WHERE key = ?", (key,))
row = cursor.fetchone()
return row[0] if row else default
def main():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Self-heal: ensure the table exists even if create_db.py was skipped during OTA.
# Duplicates the canonical schema from sqlite/create_db.py — keep them in sync.
cursor.execute("""
CREATE TABLE IF NOT EXISTS data_CCS811 (
timestamp TEXT,
eCO2 INTEGER,
TVOC INTEGER
)
""")
conn.commit()
addr_str = get_config(cursor, "CCS811_address", "0x5A")
try:
address = int(str(addr_str), 16)
except ValueError:
address = DEFAULT_ADDRESS
try:
import board
import busio
import adafruit_ccs811
except Exception as e:
print(f"CCS811: library import failed: {e}")
conn.close()
sys.exit(1)
try:
i2c = busio.I2C(board.SCL, board.SDA)
ccs811 = adafruit_ccs811.CCS811(i2c, address=address)
except Exception as e:
print(f"CCS811: cannot init at {hex(address)}: {e}")
conn.close()
sys.exit(1)
try:
ready = False
for _ in range(DATA_READY_RETRIES):
if ccs811.data_ready:
ready = True
break
time.sleep(DATA_READY_DELAY)
if not ready:
print("CCS811: data not ready (warming up?), skipping.")
return
eco2 = int(ccs811.eco2)
tvoc = int(ccs811.tvoc)
cursor.execute("SELECT last_updated FROM timestamp_table LIMIT 1")
row = cursor.fetchone()
rtc_time_str = row[0]
cursor.execute(
"INSERT INTO data_CCS811 (timestamp, eCO2, TVOC) VALUES (?, ?, ?)",
(rtc_time_str, eco2, tvoc),
)
conn.commit()
print(f"eCO2: {eco2} ppm, TVOC: {tvoc} ppb (saved at {rtc_time_str})")
except Exception as e:
print(f"CCS811 error: {e}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -23,6 +23,7 @@ NebuleAir Pro 4G is an environmental monitoring system running on Raspberry Pi 4
- NSRT MK4: Noise sensor via I2C (0x48)
- SARA R4/R5: 4G cellular modem (ttyAMA2)
- Senseair S88: CO2 sensor via Modbus RTU (any free ttyAMA — port configurable, see admin.html)
- CCS811: air-quality MOX sensor (TVOC + eCO2) via I2C (0x5A or 0x5B, configurable). Note: eCO2 is *derived* from VOCs, not a true NDIR CO2 measurement like the S88.
- Wind meter: via ADS1115 ADC
- MPPT: Solar charger monitoring
@@ -49,6 +50,7 @@ When adding a new UART sensor (e.g. S88), it goes on one of the free NPM connect
- `NPM/`: NextPM sensor scripts
- `envea/`: Envea sensor scripts
- `BME280/`: BME280 sensor scripts
- `CCS811/`: CCS811 air-quality sensor scripts (TVOC/eCO2, I2C)
- `sound_meter/`: Noise sensor code (C program)
- `SARA/`: 4G modem communication (AT commands)
- `windMeter/`: Wind sensor scripts
@@ -102,6 +104,7 @@ sudo /var/www/nebuleair_pro_4g/services/setup_services.sh
- `nebuleair-envea-data.timer`: Every 10 seconds (Envea sensors)
- `nebuleair-sara-data.timer`: Every 60 seconds (4G data transmission)
- `nebuleair-bme280-data.timer`: Every 120 seconds (BME280 sensor)
- `nebuleair-ccs811-data.timer`: Every 10 seconds (CCS811 TVOC/eCO2 sensor)
- `nebuleair-mppt-data.timer`: Every 120 seconds (MPPT charger)
- `nebuleair-noise-data.timer`: Every 60 seconds (Noise sensor)
- `nebuleair-db-cleanup-data.timer`: Daily (database cleanup)

View File

@@ -1 +1 @@
1.9.19
1.10.0

View File

@@ -1,5 +1,18 @@
{
"versions": [
{
"version": "1.10.0",
"date": "2026-06-02",
"changes": {
"features": [
"Intégration du capteur de qualité d'air CCS811 (I2C). Mesure TVOC (ppb, mesure principale) + eCO2 (ppm, dérivé des COV — PAS un vrai CO2 NDIR comme le S88). Nouveau dossier CCS811/ (get_data.py lecture live + write_data.py timer). Table data_CCS811 (timestamp, eCO2, TVOC). Timer systemd toutes les 10 s. Activation + adresse I2C (0x5A Adafruit / 0x5B SparkFun, défaut 0x5A) configurables dans admin.html. Carte 'Get Data' dans sensors.html, consultation/export dans database.html. Lib adafruit-circuitpython-ccs811 ajoutée à installation_part1.sh."
],
"improvements": [],
"fixes": [],
"compatibility": []
},
"notes": "⚠ Matériel : le CCS811 utilise le clock-stretching I2C que le contrôleur du Pi gère mal (bug BSC). Prévoir 'dtparam=i2c_arm_baudrate=10000' dans config.txt si les lectures échouent en I/O error — à valider au bench. Voir CCS811/README.md. Schéma data_CCS811 dupliqué dans write_data.py (self-heal CREATE IF NOT EXISTS) et create_db.py — garder synchro. CCS811 pas encore intégré au payload de transmission (local-only, comme le S88)."
},
{
"version": "1.9.19",
"date": "2026-06-01",

View File

@@ -154,6 +154,20 @@
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="" id="check_ccs811" onchange="update_config_sqlite('CCS811', this.checked)">
<label class="form-check-label" for="check_ccs811">
Send VOC sensor data (CCS811)
</label>
<div class="mt-2 ms-4" style="max-width: 250px;">
<label for="ccs811_address" class="form-label small mb-1">Adresse I2C du capteur CCS811</label>
<select class="form-select form-select-sm" id="ccs811_address" onchange="update_config_sqlite('CCS811_address', this.value)">
<option value="0x5A">0x5A (Adafruit, ADDR=GND)</option>
<option value="0x5B">0x5B (SparkFun, ADDR=VDD)</option>
</select>
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="" id="check_wifi_power_saving" onchange="update_config_sqlite('wifi_power_saving', this.checked)">
<label class="form-check-label" for="check_wifi_power_saving">
@@ -596,6 +610,7 @@ window.onload = function() {
const checkbox_noise = document.getElementById("check_NOISE");
const checkbox_mhz19 = document.getElementById("check_mhz19");
const checkbox_s88 = document.getElementById("check_s88");
const checkbox_ccs811 = document.getElementById("check_ccs811");
const checkbox_wifi_power_saving = document.getElementById("check_wifi_power_saving");
checkbox_bme.checked = response["BME280"];
@@ -609,6 +624,10 @@ window.onload = function() {
if (response["S88_port"]) {
document.getElementById("s88_port").value = response["S88_port"];
}
checkbox_ccs811.checked = response["CCS811"];
if (response["CCS811_address"]) {
document.getElementById("ccs811_address").value = response["CCS811_address"];
}
checkbox_wifi_power_saving.checked = response["wifi_power_saving"];
checkbox_uSpot.checked = response["send_uSpot"];

View File

@@ -75,6 +75,7 @@
<button class="btn btn-primary mb-2" onclick="openTableModal('data_MPPT','Batterie')" data-i18n="database.battery">Batterie</button>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_MHZ19','Mesures CO2 (MH-Z19)')">Mesures CO2 (MH-Z19)</button>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_S88','Mesures CO2 (Senseair S88)')">Mesures CO2 (Senseair S88)</button>
<button class="btn btn-primary mb-2" onclick="openTableModal('data_CCS811','Mesures TVOC/eCO2 (CCS811)')">Mesures TVOC/eCO2 (CCS811)</button>
<button class="btn btn-warning mb-2" onclick="openTableModal('timestamp_table','Timestamp Table')" data-i18n="database.timestampTable">Timestamp Table</button>
</div>
</div>
@@ -100,6 +101,7 @@
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_mppt')" data-i18n="database.battery">Batterie</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_MHZ19')">Mesures CO2 (MH-Z19)</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_S88')">Mesures CO2 (Senseair S88)</button>
<button class="btn btn-primary mb-2" onclick="downloadByDate('data_CCS811')">Mesures TVOC/eCO2 (CCS811)</button>
</div>
</div>
</div>
@@ -117,6 +119,7 @@
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_MPPT')" data-i18n="database.battery">Batterie</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_MHZ19')">Mesures CO2 (MH-Z19)</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_S88')">Mesures CO2 (Senseair S88)</button>
<button class="btn btn-success mb-2" onclick="downloadFullTable('data_CCS811')">Mesures TVOC/eCO2 (CCS811)</button>
</div>
</div>
</div>
@@ -320,7 +323,8 @@ function buildTableHeader(table) {
data_MPPT: ['Timestamp','Battery Voltage','Battery Current','solar_voltage','solar_power','charger_status'],
data_NOISE: ['Timestamp','Curent LEQ','DB_A_value','Status'],
data_MHZ19: ['Timestamp','CO2 (ppm)'],
data_S88: ['Timestamp','CO2 (ppm)']
data_S88: ['Timestamp','CO2 (ppm)'],
data_CCS811: ['Timestamp','eCO2 (ppm)','TVOC (ppb)']
};
return (headers[table] || ['Data']).map(h => `<th>${h}</th>`).join('');
}
@@ -343,7 +347,7 @@ function buildTableRow(table, columns) {
return `<td>${columns[1]}</td>`;
}
// Default: render all available columns
const colCount = { data_BME280: 4, data_NPM_5channels: 6, data_envea: 6, data_WIND: 3, data_MPPT: 6, data_MHZ19: 2, data_S88: 2 };
const colCount = { data_BME280: 4, data_NPM_5channels: 6, data_envea: 6, data_WIND: 3, data_MPPT: 6, data_MHZ19: 2, data_S88: 2, data_CCS811: 3 };
const n = colCount[table] || columns.length;
return columns.slice(0, n).map(c => `<td>${c}</td>`).join('');
}
@@ -485,6 +489,9 @@ function downloadCSV(response, table) {
else if (table === "data_S88") {
csvContent += "TimestampUTC,CO2_ppm\n";
}
else if (table === "data_CCS811") {
csvContent += "TimestampUTC,eCO2_ppm,TVOC_ppb\n";
}
// Format rows as CSV
rows.forEach(row => {
@@ -513,7 +520,8 @@ const tableDisplayNames = {
'data_MPPT': 'Batterie (MPPT)',
'data_NOISE': 'Bruit',
'data_MHZ19': 'CO2 (MH-Z19)',
'data_S88': 'CO2 (Senseair S88)'
'data_S88': 'CO2 (Senseair S88)',
'data_CCS811': 'TVOC/eCO2 (CCS811)'
};
function loadDbStats() {

View File

@@ -794,7 +794,7 @@ if ($type == "db_table_stats") {
$fileSizeMB = round($fileSizeBytes / (1024 * 1024), 2);
// Sensor data tables to inspect
$tables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88'];
$tables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88', 'data_CCS811'];
$tableStats = [];
foreach ($tables as $tableName) {
@@ -844,7 +844,7 @@ if ($type == "download_full_table") {
$table = $_GET['table'] ?? '';
// Whitelist of allowed tables
$allowedTables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88'];
$allowedTables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88', 'data_CCS811'];
if (!in_array($table, $allowedTables)) {
header('Content-Type: application/json');
@@ -862,7 +862,8 @@ if ($type == "download_full_table") {
'data_MPPT' => 'TimestampUTC,Battery_voltage,Battery_current,Solar_voltage,Solar_power,Charger_status',
'data_NOISE' => 'TimestampUTC,Current_LEQ,DB_A_value',
'data_MHZ19' => 'TimestampUTC,CO2_ppm',
'data_S88' => 'TimestampUTC,CO2_ppm'
'data_S88' => 'TimestampUTC,CO2_ppm',
'data_CCS811' => 'TimestampUTC,eCO2_ppm,TVOC_ppb'
];
try {
@@ -1016,6 +1017,12 @@ if ($type == "s88") {
echo $output;
}
if ($type == "ccs811") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/get_data.py';
$output = shell_exec($command);
echo $output;
}
if ($type == "table_mesure") {
$table=$_GET['table'];
@@ -1674,6 +1681,10 @@ if ($type == "get_systemd_services") {
'description' => 'Reads CO2 concentration from MH-Z19 sensor',
'frequency' => 'Every 2 minutes'
],
'nebuleair-ccs811-data.timer' => [
'description' => 'Reads eCO2/TVOC from CCS811 air-quality sensor',
'frequency' => 'Every 10 seconds'
],
'nebuleair-s88-data.timer' => [
'description' => 'Reads CO2 concentration from Senseair S88 sensor',
'frequency' => 'Every 10 seconds'
@@ -1765,6 +1776,7 @@ if ($type == "restart_systemd_service") {
'nebuleair-noise-data.timer',
'nebuleair-mhz19-data.timer',
'nebuleair-s88-data.timer',
'nebuleair-ccs811-data.timer',
'nebuleair-db-cleanup-data.timer',
'nebuleair-wifi-powersave.timer',
'nebuleair-cpu-power.service',
@@ -1831,6 +1843,7 @@ if ($type == "toggle_systemd_service") {
'nebuleair-noise-data.timer',
'nebuleair-mhz19-data.timer',
'nebuleair-s88-data.timer',
'nebuleair-ccs811-data.timer',
'nebuleair-db-cleanup-data.timer',
'nebuleair-wifi-powersave.timer',
'nebuleair-cpu-power.service',

View File

@@ -409,6 +409,62 @@
});
}
function getCCS811_values() {
console.log("Data from CCS811 air-quality sensor:");
$("#loading_ccs811").show();
$.ajax({
url: 'launcher.php?type=ccs811',
dataType: 'json',
method: 'GET',
success: function (response) {
console.log(response);
const tableBody = document.getElementById("data-table-body_ccs811");
tableBody.innerHTML = "";
$("#loading_ccs811").hide();
if (response.error) {
$("#data-table-body_ccs811").append(`
<tr>
<td colspan="2" class="text-danger">
${response.error}
</td>
</tr>
`);
} else {
if (response.TVOC !== undefined) {
$("#data-table-body_ccs811").append(`
<tr>
<td>TVOC</td>
<td>${response.TVOC} ppb</td>
</tr>
`);
}
if (response.eCO2 !== undefined) {
$("#data-table-body_ccs811").append(`
<tr>
<td>eCO2</td>
<td>${response.eCO2} ppm</td>
</tr>
`);
}
}
},
error: function (xhr, status, error) {
console.error('AJAX request failed:', status, error);
$("#loading_ccs811").hide();
const tableBody = document.getElementById("data-table-body_ccs811");
tableBody.innerHTML = `
<tr>
<td colspan="2" class="text-danger">
⚠ Erreur de communication avec le capteur
</td>
</tr>
`;
}
});
}
function getMHZ19_values() {
console.log("Data from MH-Z19 CO2 sensor:");
$("#loading_mhz19").show();
@@ -675,6 +731,29 @@
container.innerHTML += S88_HTML;
}
//creates CCS811 air-quality (eCO2/TVOC) card
if (config.CCS811) {
const CCS811_HTML = `
<div class="col-sm-3">
<div class="card">
<div class="card-header">
I2C
</div>
<div class="card-body">
<h5 class="card-title">CCS811 (TVOC / eCO2)</h5>
<p class="card-text">Capteur de composés organiques volatils.</p>
<button class="btn btn-primary mb-1" onclick="getCCS811_values()" data-i18n="common.getData">Get Data</button>
<div id="loading_ccs811" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
<table class="table table-striped-columns">
<tbody id="data-table-body_ccs811"></tbody>
</table>
</div>
</div>
</div>`;
container.innerHTML += CCS811_HTML;
}
//Si on a des SONDES ENVEA connectée il faut faire un deuxième call dans la table envea_sondes_table
//creates ENVEA debug card
if (config.envea) {

View File

@@ -27,7 +27,7 @@ sudo apt update && sudo apt install -y git gh apache2 sqlite3 php php-sqlite3 py
# Install Python libraries
info "Installing Python libraries..."
sudo pip3 install pyserial requests adafruit-circuitpython-bme280 crcmod psutil gpiozero ntplib adafruit-circuitpython-ads1x15 nsrt-mk3-dev pytz --break-system-packages || error "Failed to install Python libraries."
sudo pip3 install pyserial requests adafruit-circuitpython-bme280 adafruit-circuitpython-ccs811 crcmod psutil gpiozero ntplib adafruit-circuitpython-ads1x15 nsrt-mk3-dev pytz --break-system-packages || error "Failed to install Python libraries."
# Install Tailscale (for remote SSH access via Headscale tailnet)
info "Installing Tailscale..."

View File

@@ -269,6 +269,38 @@ AccuracySec=1s
WantedBy=timers.target
EOL
# Create service and timer files for CCS811 air-quality (eCO2/TVOC) Data
cat > /etc/systemd/system/nebuleair-ccs811-data.service << 'EOL'
[Unit]
Description=NebuleAir CCS811 Air-Quality (eCO2/TVOC) Data Collection Service
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/CCS811/write_data.py
User=root
WorkingDirectory=/var/www/nebuleair_pro_4g
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/ccs811_service.log
StandardError=append:/var/www/nebuleair_pro_4g/logs/ccs811_service_errors.log
[Install]
WantedBy=multi-user.target
EOL
cat > /etc/systemd/system/nebuleair-ccs811-data.timer << 'EOL'
[Unit]
Description=Run NebuleAir CCS811 Air-Quality Data Collection every 10 seconds
Requires=nebuleair-ccs811-data.service
[Timer]
OnBootSec=10s
OnUnitActiveSec=10s
AccuracySec=1s
[Install]
WantedBy=timers.target
EOL
# Create service and timer files for Database Cleanup
cat > /etc/systemd/system/nebuleair-db-cleanup-data.service << 'EOL'
[Unit]
@@ -402,7 +434,7 @@ systemctl daemon-reload
# Enable and start all timers
echo "Enabling and starting all services..."
for service in npm envea sara bme280 mppt mhz19 s88 db-cleanup noise; do
for service in npm envea sara bme280 mppt mhz19 s88 ccs811 db-cleanup noise; do
systemctl enable nebuleair-$service-data.timer
systemctl start nebuleair-$service-data.timer
echo "Started nebuleair-$service-data timer"

View File

@@ -168,6 +168,15 @@ CREATE TABLE IF NOT EXISTS data_S88 (
)
""")
# Create a table CCS811 (AMS CCS811 air-quality sensor: eCO2 + TVOC)
cursor.execute("""
CREATE TABLE IF NOT EXISTS data_CCS811 (
timestamp TEXT,
eCO2 INTEGER,
TVOC INTEGER
)
""")
# Commit and close the connection
conn.commit()
conn.close()

View File

@@ -53,6 +53,8 @@ config_entries = [
("MHZ19", "0", "bool"),
("S88", "0", "bool"),
("S88_port", "/dev/ttyAMA5", "str"),
("CCS811", "0", "bool"),
("CCS811_address", "0x5A", "str"),
("modem_version", "XXX", "str"),
("device_type", "nebuleair_pro", "str"),
("language", "fr", "str"),