v1.9.13: Capteur CO2 Senseair S88 - scaffolding
Table data_S88, flag config S88 + port configurable S88_port (default /dev/ttyAMA5), service/timer systemd 10s, carte sensors.html, endpoint launcher.php, toggle admin.html. read_co2() est un stub NotImplementedError en attente du datasheet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
72
S88/get_data.py
Normal file
72
S88/get_data.py
Normal file
@@ -0,0 +1,72 @@
|
||||
'''
|
||||
Live read of the Senseair S88 CO2 sensor (used by the web "Get Data" button).
|
||||
Prints a JSON object: {"CO2": <int_ppm>} or {"error": "<message>"}.
|
||||
|
||||
Usage: /usr/bin/python3 /var/www/nebuleair_pro_4g/S88/get_data.py [port]
|
||||
If no port is given, the script reads S88_port from config_table.
|
||||
'''
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import serial
|
||||
|
||||
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
|
||||
DEFAULT_PORT = "/dev/ttyAMA5"
|
||||
BAUDRATE = 9600
|
||||
|
||||
|
||||
def get_port_from_config():
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT value FROM config_table WHERE key = ?", ("S88_port",))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return row[0] if row else DEFAULT_PORT
|
||||
except Exception:
|
||||
return DEFAULT_PORT
|
||||
|
||||
|
||||
def read_co2(ser):
|
||||
# TODO: implement the Senseair S88 read protocol once the datasheet is provided.
|
||||
# Expected return: integer CO2 concentration in ppm, or None on failure.
|
||||
raise NotImplementedError("Senseair S88 read protocol not implemented yet")
|
||||
|
||||
|
||||
def main():
|
||||
port = sys.argv[1] if len(sys.argv) > 1 else get_port_from_config()
|
||||
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=BAUDRATE,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
timeout=1,
|
||||
)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"Cannot open {port}: {e}"}))
|
||||
return
|
||||
|
||||
try:
|
||||
co2 = read_co2(ser)
|
||||
if co2 is None:
|
||||
print(json.dumps({"error": "No data from S88"}))
|
||||
return
|
||||
print(json.dumps({"CO2": int(round(co2))}))
|
||||
except NotImplementedError as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"S88 read error: {e}"}))
|
||||
finally:
|
||||
try:
|
||||
ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
81
S88/write_data.py
Normal file
81
S88/write_data.py
Normal file
@@ -0,0 +1,81 @@
|
||||
'''
|
||||
Script to get CO2 values from Senseair S88 sensor and write to database
|
||||
/usr/bin/python3 /var/www/nebuleair_pro_4g/S88/write_data.py
|
||||
|
||||
Port and protocol details come from config_table (key S88_port).
|
||||
The actual sensor read protocol is implemented in read_co2() below.
|
||||
'''
|
||||
|
||||
import serial
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
|
||||
DEFAULT_PORT = "/dev/ttyAMA5"
|
||||
BAUDRATE = 9600
|
||||
|
||||
|
||||
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 read_co2(ser):
|
||||
# TODO: implement the Senseair S88 read protocol once the datasheet is provided.
|
||||
# Expected return: integer CO2 concentration in ppm, or None on failure.
|
||||
raise NotImplementedError("Senseair S88 read protocol not implemented yet")
|
||||
|
||||
|
||||
def main():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
port = get_config(cursor, "S88_port", DEFAULT_PORT)
|
||||
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=BAUDRATE,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
timeout=1,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error opening serial port {port}: {e}")
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
co2 = read_co2(ser)
|
||||
if co2 is None:
|
||||
print("Failed to read CO2 from S88.")
|
||||
return
|
||||
|
||||
co2_ppm = int(round(co2))
|
||||
|
||||
cursor.execute("SELECT last_updated FROM timestamp_table LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
rtc_time_str = row[0]
|
||||
|
||||
cursor.execute(
|
||||
"INSERT INTO data_S88 (timestamp, CO2) VALUES (?, ?)",
|
||||
(rtc_time_str, co2_ppm),
|
||||
)
|
||||
conn.commit()
|
||||
print(f"CO2: {co2_ppm} ppm (saved at {rtc_time_str})")
|
||||
except NotImplementedError as e:
|
||||
print(f"S88 not ready: {e}")
|
||||
except Exception as e:
|
||||
print(f"S88 error: {e}")
|
||||
finally:
|
||||
try:
|
||||
ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.9.13",
|
||||
"date": "2026-06-01",
|
||||
"changes": {
|
||||
"features": [
|
||||
"Capteur CO2 Senseair S88: scaffolding complet (table SQLite data_S88, flag config S88 + port configurable S88_port par défaut /dev/ttyAMA5, service/timer systemd 10s, carte sensors.html, endpoint launcher.php ?type=s88, toggle admin.html 'Send CO2 sensor data'). La fonction read_co2() est un stub NotImplementedError en attente du datasheet du protocole — le service tourne mais log l'erreur sans planter."
|
||||
],
|
||||
"improvements": [],
|
||||
"fixes": [],
|
||||
"compatibility": []
|
||||
},
|
||||
"notes": "Ajout du capteur CO2 Senseair S88. Pour activer après MAJ: exécuter sqlite/create_db.py + sqlite/set_config.py (pour la migration table+config), puis services/setup_services.sh. La lecture sensor est désactivée tant que le datasheet n'est pas intégré."
|
||||
},
|
||||
{
|
||||
"version": "1.9.12",
|
||||
"date": "2026-05-28",
|
||||
|
||||
@@ -139,6 +139,13 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" value="" id="check_s88" onchange="update_config_sqlite('S88', this.checked)">
|
||||
<label class="form-check-label" for="check_s88">
|
||||
Send CO2 sensor data (Senseair S88)
|
||||
</label>
|
||||
</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">
|
||||
@@ -580,6 +587,7 @@ window.onload = function() {
|
||||
const checkbox_solar = document.getElementById("check_solarBattery");
|
||||
const checkbox_noise = document.getElementById("check_NOISE");
|
||||
const checkbox_mhz19 = document.getElementById("check_mhz19");
|
||||
const checkbox_s88 = document.getElementById("check_s88");
|
||||
const checkbox_wifi_power_saving = document.getElementById("check_wifi_power_saving");
|
||||
|
||||
checkbox_bme.checked = response["BME280"];
|
||||
@@ -589,6 +597,7 @@ window.onload = function() {
|
||||
checkbox_wind.checked = response["windMeter"];
|
||||
checkbox_noise.checked = response["NOISE"];
|
||||
checkbox_mhz19.checked = response["MHZ19"];
|
||||
checkbox_s88.checked = response["S88"];
|
||||
checkbox_wifi_power_saving.checked = response["wifi_power_saving"];
|
||||
|
||||
checkbox_uSpot.checked = response["send_uSpot"];
|
||||
|
||||
@@ -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'];
|
||||
$tables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88'];
|
||||
|
||||
$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'];
|
||||
$allowedTables = ['data_NPM', 'data_NPM_5channels', 'data_BME280', 'data_envea', 'data_WIND', 'data_MPPT', 'data_NOISE', 'data_MHZ19', 'data_S88'];
|
||||
|
||||
if (!in_array($table, $allowedTables)) {
|
||||
header('Content-Type: application/json');
|
||||
@@ -861,7 +861,8 @@ if ($type == "download_full_table") {
|
||||
'data_WIND' => 'TimestampUTC,Wind_speed_kmh,Wind_direction_V',
|
||||
'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_MHZ19' => 'TimestampUTC,CO2_ppm',
|
||||
'data_S88' => 'TimestampUTC,CO2_ppm'
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -1009,6 +1010,12 @@ if ($type == "mhz19") {
|
||||
echo $output;
|
||||
}
|
||||
|
||||
if ($type == "s88") {
|
||||
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/S88/get_data.py';
|
||||
$output = shell_exec($command);
|
||||
echo $output;
|
||||
}
|
||||
|
||||
|
||||
if ($type == "table_mesure") {
|
||||
$table=$_GET['table'];
|
||||
@@ -1666,6 +1673,10 @@ if ($type == "get_systemd_services") {
|
||||
'description' => 'Reads CO2 concentration from MH-Z19 sensor',
|
||||
'frequency' => 'Every 2 minutes'
|
||||
],
|
||||
'nebuleair-s88-data.timer' => [
|
||||
'description' => 'Reads CO2 concentration from Senseair S88 sensor',
|
||||
'frequency' => 'Every 10 seconds'
|
||||
],
|
||||
'nebuleair-db-cleanup-data.timer' => [
|
||||
'description' => 'Cleans up old data from database',
|
||||
'frequency' => 'Daily'
|
||||
@@ -1752,6 +1763,7 @@ if ($type == "restart_systemd_service") {
|
||||
'nebuleair-mppt-data.timer',
|
||||
'nebuleair-noise-data.timer',
|
||||
'nebuleair-mhz19-data.timer',
|
||||
'nebuleair-s88-data.timer',
|
||||
'nebuleair-db-cleanup-data.timer',
|
||||
'nebuleair-wifi-powersave.timer',
|
||||
'nebuleair-cpu-power.service',
|
||||
@@ -1817,6 +1829,7 @@ if ($type == "toggle_systemd_service") {
|
||||
'nebuleair-mppt-data.timer',
|
||||
'nebuleair-noise-data.timer',
|
||||
'nebuleair-mhz19-data.timer',
|
||||
'nebuleair-s88-data.timer',
|
||||
'nebuleair-db-cleanup-data.timer',
|
||||
'nebuleair-wifi-powersave.timer',
|
||||
'nebuleair-cpu-power.service',
|
||||
|
||||
@@ -363,6 +363,52 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getS88_values() {
|
||||
console.log("Data from Senseair S88 CO2 sensor:");
|
||||
$("#loading_s88").show();
|
||||
|
||||
$.ajax({
|
||||
url: 'launcher.php?type=s88',
|
||||
dataType: 'json',
|
||||
method: 'GET',
|
||||
success: function (response) {
|
||||
console.log(response);
|
||||
const tableBody = document.getElementById("data-table-body_s88");
|
||||
tableBody.innerHTML = "";
|
||||
$("#loading_s88").hide();
|
||||
|
||||
if (response.error) {
|
||||
$("#data-table-body_s88").append(`
|
||||
<tr>
|
||||
<td colspan="2" class="text-danger">
|
||||
⚠ ${response.error}
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
} else if (response.CO2 !== undefined) {
|
||||
$("#data-table-body_s88").append(`
|
||||
<tr>
|
||||
<td>CO2</td>
|
||||
<td>${response.CO2} ppm</td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
console.error('AJAX request failed:', status, error);
|
||||
$("#loading_s88").hide();
|
||||
const tableBody = document.getElementById("data-table-body_s88");
|
||||
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();
|
||||
@@ -606,6 +652,29 @@
|
||||
container.innerHTML += MHZ19_HTML;
|
||||
}
|
||||
|
||||
//creates Senseair S88 CO2 card
|
||||
if (config.S88) {
|
||||
const S88_HTML = `
|
||||
<div class="col-sm-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Port UART
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Senseair S88 CO2</h5>
|
||||
<p class="card-text">Capteur de dioxyde de carbone.</p>
|
||||
<button class="btn btn-primary mb-1" onclick="getS88_values()" data-i18n="common.getData">Get Data</button>
|
||||
<div id="loading_s88" class="spinner-border spinner-border-sm" style="display: none;" role="status"></div>
|
||||
<table class="table table-striped-columns">
|
||||
<tbody id="data-table-body_s88"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML += S88_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) {
|
||||
|
||||
@@ -237,6 +237,38 @@ AccuracySec=1s
|
||||
WantedBy=timers.target
|
||||
EOL
|
||||
|
||||
# Create service and timer files for Senseair S88 CO2 Data
|
||||
cat > /etc/systemd/system/nebuleair-s88-data.service << 'EOL'
|
||||
[Unit]
|
||||
Description=NebuleAir Senseair S88 CO2 Data Collection Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/S88/write_data.py
|
||||
User=root
|
||||
WorkingDirectory=/var/www/nebuleair_pro_4g
|
||||
StandardOutput=append:/var/www/nebuleair_pro_4g/logs/s88_service.log
|
||||
StandardError=append:/var/www/nebuleair_pro_4g/logs/s88_service_errors.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOL
|
||||
|
||||
cat > /etc/systemd/system/nebuleair-s88-data.timer << 'EOL'
|
||||
[Unit]
|
||||
Description=Run NebuleAir Senseair S88 CO2 Data Collection every 10 seconds
|
||||
Requires=nebuleair-s88-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]
|
||||
@@ -370,7 +402,7 @@ systemctl daemon-reload
|
||||
|
||||
# Enable and start all timers
|
||||
echo "Enabling and starting all services..."
|
||||
for service in npm envea sara bme280 mppt mhz19 db-cleanup noise; do
|
||||
for service in npm envea sara bme280 mppt mhz19 s88 db-cleanup noise; do
|
||||
systemctl enable nebuleair-$service-data.timer
|
||||
systemctl start nebuleair-$service-data.timer
|
||||
echo "Started nebuleair-$service-data timer"
|
||||
|
||||
@@ -160,6 +160,14 @@ CREATE TABLE IF NOT EXISTS data_MHZ19 (
|
||||
)
|
||||
""")
|
||||
|
||||
# Create a table S88 (Senseair S88 CO2 sensor)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS data_S88 (
|
||||
timestamp TEXT,
|
||||
CO2 INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
# Commit and close the connection
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -51,6 +51,8 @@ config_entries = [
|
||||
("MPPT", "0", "bool"),
|
||||
("NOISE", "0", "bool"),
|
||||
("MHZ19", "0", "bool"),
|
||||
("S88", "0", "bool"),
|
||||
("S88_port", "/dev/ttyAMA5", "str"),
|
||||
("modem_version", "XXX", "str"),
|
||||
("device_type", "nebuleair_pro", "str"),
|
||||
("language", "fr", "str"),
|
||||
|
||||
Reference in New Issue
Block a user