mirror of
https://github.com/airgradienthq/arduino.git
synced 2026-06-11 19:51:22 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7529ccb89 | |||
| 783b765df6 | |||
| d47331c9de | |||
| 1906cc1606 | |||
| 2829e1f5a8 | |||
| 7068ede0fc | |||
| 733ddf17ef | |||
| 0b7c8c0cb7 |
@@ -142,7 +142,7 @@ If the monitor is set up on the AirGradient dashboard, it will also receive the
|
||||
| `country` | Country where the device is. | String | Country code as [ALPHA-2 notation](https://www.iban.com/country-codes) | `{"country": "TH"}` |
|
||||
| `model` | Hardware identifier (only GET). | String | I-9PSL-DE | `{"model": "I-9PSL-DE"}` |
|
||||
| `pmStandard` | Particle matter standard used on the display. | String | `ugm3`: ug/m3 <br> `us-aqi`: USAQI | `{"pmStandard": "ugm3"}` |
|
||||
| `ledBarMode` | Mode in which the led bar can be set. | String | `co2`: LED bar displays CO2 <br>`pm`: LED bar displays PM <br>`off`: Turn off LED bar | `{"ledBarMode": "off"}` |
|
||||
| `ledBarMode` | Mode in which the led bar can be set. | String | `co2`: LED bar displays CO2 <br>`pm`: LED bar displays PM <br>`iaqs`: LED bar displays GO IAQS Starter Score (PM2.5 + CO2) <br>`off`: Turn off LED bar | `{"ledBarMode": "off"}` |
|
||||
| `displayBrightness` | Brightness of the Display. | Number | 0-100 | `{"displayBrightness": 50}` |
|
||||
| `ledBarBrightness` | Brightness of the LEDBar. | Number | 0-100 | `{"ledBarBrightness": 40}` |
|
||||
| `abcDays` | Number of days for CO2 automatic baseline calibration. | Number | Maximum 200 days. Default 8 days. | `{"abcDays": 8}` |
|
||||
|
||||
@@ -140,7 +140,7 @@ static void configUpdateHandle(void);
|
||||
static void updateDisplayAndLedBar(void);
|
||||
static void updateTvoc(void);
|
||||
static void updatePm(void);
|
||||
static void updateSPS30(void);
|
||||
static void updateSPS30(SPS30 &sensor, int channel);
|
||||
static void sendDataToServer(void);
|
||||
static void tempHumUpdate(void);
|
||||
static void co2Update(void);
|
||||
@@ -336,7 +336,7 @@ void loop() {
|
||||
co2Schedule.run();
|
||||
}
|
||||
if (configuration.hasSensorPMS1 || configuration.hasSensorPMS2 ||
|
||||
configuration.hasSensorSPS30) {
|
||||
configuration.hasSensorSPS30_1 || configuration.hasSensorSPS30_2) {
|
||||
pmsSchedule.run();
|
||||
}
|
||||
if (ag->isOne()) {
|
||||
@@ -829,9 +829,9 @@ static void oneIndoorInit(void) {
|
||||
Serial.println("PMS5003 not found, trying SPS30...");
|
||||
configuration.hasSensorPMS1 = false;
|
||||
|
||||
if (ag->sps30.begin(Serial0)) {
|
||||
if (ag->sps30_1.begin(Serial0)) {
|
||||
Serial.println("SPS30 detected on Serial0");
|
||||
configuration.hasSensorSPS30 = true;
|
||||
configuration.hasSensorSPS30_1 = true;
|
||||
} else {
|
||||
Serial.println("SPS30 not found either");
|
||||
dispSensorNotFound("PM sensor");
|
||||
@@ -888,56 +888,100 @@ static void openAirInit(void) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Attempt to detect PM sensors */
|
||||
if (fwMode == FW_MODE_O_1PST) {
|
||||
bool pmInitSuccess = false;
|
||||
/**
|
||||
* Attempt to detect PM sensors on available serial ports.
|
||||
* Per-port order: PMS5003T first (@9600), fallback to SPS30 (@115200).
|
||||
* For single-PM modes the detected sensor is always assigned to channel 1.
|
||||
* For dual-PM modes Serial0 → channel 1, Serial1 → channel 2.
|
||||
*/
|
||||
auto detectPmOnSerial = [](HardwareSerial &serial, PMS5003T &pms, SPS30 &sps,
|
||||
const char *portName) -> int {
|
||||
// Returns: 0 = none, 1 = PMS5003T, 2 = SPS30
|
||||
if (pms.begin(serial)) {
|
||||
Serial.printf("Detected PMS5003T on %s\n", portName);
|
||||
return 1;
|
||||
}
|
||||
Serial.printf("PMS5003T not found on %s, trying SPS30...\n", portName);
|
||||
if (sps.begin(serial)) {
|
||||
Serial.printf("Detected SPS30 on %s\n", portName);
|
||||
return 2;
|
||||
}
|
||||
Serial.printf("No PM sensor detected on %s\n", portName);
|
||||
return 0;
|
||||
};
|
||||
|
||||
if (fwMode == FW_MODE_O_1PST || fwMode == FW_MODE_O_1PS) {
|
||||
// Single PM channel expected — try Serial0 first, fallback Serial1
|
||||
configuration.hasSensorPMS1 = false;
|
||||
configuration.hasSensorPMS2 = false;
|
||||
bool pmFound = false;
|
||||
|
||||
if (serial0Available) {
|
||||
if (ag->pms5003t_1.begin(Serial0) == false) {
|
||||
configuration.hasSensorPMS1 = false;
|
||||
Serial.println("No PM sensor detected on Serial0");
|
||||
} else {
|
||||
int result =
|
||||
detectPmOnSerial(Serial0, ag->pms5003t_1, ag->sps30_1, "Serial0");
|
||||
if (result == 1) {
|
||||
configuration.hasSensorPMS1 = true;
|
||||
serial0Available = false;
|
||||
pmInitSuccess = true;
|
||||
Serial.println("Detected PM 1 on Serial0");
|
||||
pmFound = true;
|
||||
} else if (result == 2) {
|
||||
configuration.hasSensorSPS30_1 = true;
|
||||
serial0Available = false;
|
||||
pmFound = true;
|
||||
}
|
||||
}
|
||||
if (pmInitSuccess == false) {
|
||||
if (serial1Available) {
|
||||
if (ag->pms5003t_1.begin(Serial1) == false) {
|
||||
configuration.hasSensorPMS1 = false;
|
||||
Serial.println("No PM sensor detected on Serial1");
|
||||
} else {
|
||||
serial1Available = false;
|
||||
Serial.println("Detected PM 1 on Serial1");
|
||||
}
|
||||
if (!pmFound && serial1Available) {
|
||||
int result =
|
||||
detectPmOnSerial(Serial1, ag->pms5003t_1, ag->sps30_1, "Serial1");
|
||||
if (result == 1) {
|
||||
configuration.hasSensorPMS1 = true;
|
||||
serial1Available = false;
|
||||
} else if (result == 2) {
|
||||
configuration.hasSensorSPS30_1 = true;
|
||||
serial1Available = false;
|
||||
}
|
||||
}
|
||||
configuration.hasSensorPMS2 = false; // Disable PM2
|
||||
} else {
|
||||
if (ag->pms5003t_1.begin(Serial0) == false) {
|
||||
configuration.hasSensorPMS1 = false;
|
||||
Serial.println("No PM sensor detected on Serial0");
|
||||
} else {
|
||||
Serial.println("Detected PM 1 on Serial0");
|
||||
}
|
||||
if (ag->pms5003t_2.begin(Serial1) == false) {
|
||||
configuration.hasSensorPMS2 = false;
|
||||
Serial.println("No PM sensor detected on Serial1");
|
||||
} else {
|
||||
Serial.println("Detected PM 2 on Serial1");
|
||||
// Dual PM channel modes (O_1PPT / O_1PP) — Serial0 → ch1, Serial1 → ch2
|
||||
configuration.hasSensorPMS1 = false;
|
||||
configuration.hasSensorPMS2 = false;
|
||||
|
||||
// Channel 1 on Serial0
|
||||
int result1 =
|
||||
detectPmOnSerial(Serial0, ag->pms5003t_1, ag->sps30_1, "Serial0");
|
||||
if (result1 == 1) {
|
||||
configuration.hasSensorPMS1 = true;
|
||||
} else if (result1 == 2) {
|
||||
configuration.hasSensorSPS30_1 = true;
|
||||
}
|
||||
|
||||
// Channel 2 on Serial1
|
||||
int result2 =
|
||||
detectPmOnSerial(Serial1, ag->pms5003t_2, ag->sps30_2, "Serial1");
|
||||
if (result2 == 1) {
|
||||
configuration.hasSensorPMS2 = true;
|
||||
} else if (result2 == 2) {
|
||||
configuration.hasSensorSPS30_2 = true;
|
||||
}
|
||||
|
||||
// Check if we should downgrade from two-PM to single-PM mode
|
||||
if (fwMode == FW_MODE_O_1PP) {
|
||||
int count = (configuration.hasSensorPMS1 ? 1 : 0) + (configuration.hasSensorPMS2 ? 1 : 0);
|
||||
if (count == 1) {
|
||||
bool ch1HasPm =
|
||||
configuration.hasSensorPMS1 || configuration.hasSensorSPS30_1;
|
||||
bool ch2HasPm =
|
||||
configuration.hasSensorPMS2 || configuration.hasSensorSPS30_2;
|
||||
if (ch1HasPm != ch2HasPm) {
|
||||
fwMode = FW_MODE_O_1P;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** update the PMS poll period base on fw mode and sensor available */
|
||||
if (fwMode != FW_MODE_O_1PST) {
|
||||
if (configuration.hasSensorPMS1 && configuration.hasSensorPMS2) {
|
||||
/** Update the PMS poll period based on fw mode and sensor availability */
|
||||
if (fwMode != FW_MODE_O_1PST && fwMode != FW_MODE_O_1PS) {
|
||||
bool ch1HasPm =
|
||||
configuration.hasSensorPMS1 || configuration.hasSensorSPS30_1;
|
||||
bool ch2HasPm =
|
||||
configuration.hasSensorPMS2 || configuration.hasSensorSPS30_2;
|
||||
if (ch1HasPm && ch2HasPm) {
|
||||
pmsSchedule.setPeriod(2000);
|
||||
}
|
||||
}
|
||||
@@ -1316,52 +1360,53 @@ static void updatePMS5003() {
|
||||
}
|
||||
}
|
||||
|
||||
static void updateSPS30(void) {
|
||||
if (ag->sps30.readValues()) {
|
||||
static void updateSPS30(SPS30 &sensor, int channel) {
|
||||
if (sensor.readValues()) {
|
||||
// Mass concentrations — mapped to both Ae and SP (SPS30 has no distinction)
|
||||
measurements.update(Measurements::PM01, ag->sps30.getPm01Ae());
|
||||
measurements.update(Measurements::PM25, ag->sps30.getPm25Ae());
|
||||
measurements.update(Measurements::PM10, ag->sps30.getPm10Ae());
|
||||
measurements.update(Measurements::PM01_SP, ag->sps30.getPm01Sp());
|
||||
measurements.update(Measurements::PM25_SP, ag->sps30.getPm25Sp());
|
||||
measurements.update(Measurements::PM10_SP, ag->sps30.getPm10Sp());
|
||||
measurements.update(Measurements::PM01, sensor.getPm01Ae(), channel);
|
||||
measurements.update(Measurements::PM25, sensor.getPm25Ae(), channel);
|
||||
measurements.update(Measurements::PM10, sensor.getPm10Ae(), channel);
|
||||
measurements.update(Measurements::PM01_SP, sensor.getPm01Sp(), channel);
|
||||
measurements.update(Measurements::PM25_SP, sensor.getPm25Sp(), channel);
|
||||
measurements.update(Measurements::PM10_SP, sensor.getPm10Sp(), channel);
|
||||
|
||||
// Number concentrations (already converted to #/0.1L by wrapper)
|
||||
measurements.update(Measurements::PM05_PC, ag->sps30.getPm05ParticleCount());
|
||||
measurements.update(Measurements::PM01_PC, ag->sps30.getPm01ParticleCount());
|
||||
measurements.update(Measurements::PM25_PC, ag->sps30.getPm25ParticleCount());
|
||||
measurements.update(Measurements::PM10_PC, ag->sps30.getPm10ParticleCount());
|
||||
measurements.update(Measurements::PM05_PC, sensor.getPm05ParticleCount(), channel);
|
||||
measurements.update(Measurements::PM01_PC, sensor.getPm01ParticleCount(), channel);
|
||||
measurements.update(Measurements::PM25_PC, sensor.getPm25ParticleCount(), channel);
|
||||
measurements.update(Measurements::PM10_PC, sensor.getPm10ParticleCount(), channel);
|
||||
} else {
|
||||
measurements.update(Measurements::PM01, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM25, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM10, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM01_SP, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM25_SP, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM10_SP, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM01_PC, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM25_PC, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM5_PC, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM10_PC, utils::getInvalidPmValue());
|
||||
measurements.update(Measurements::PM01, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM25, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM10, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM01_SP, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM25_SP, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM10_SP, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM01_PC, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM25_PC, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM5_PC, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM10_PC, utils::getInvalidPmValue(), channel);
|
||||
}
|
||||
}
|
||||
|
||||
static void updatePm(void) {
|
||||
if (ag->isOne()) {
|
||||
if (configuration.hasSensorSPS30) {
|
||||
updateSPS30();
|
||||
if (configuration.hasSensorSPS30_1) {
|
||||
updateSPS30(ag->sps30_1, 1);
|
||||
} else {
|
||||
updatePMS5003();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Open Air Monitor series, can have two PMS5003T sensor
|
||||
bool newPMS1Value = false;
|
||||
bool newPMS2Value = false;
|
||||
// Open Air Monitor series — each channel can be PMS5003T or SPS30.
|
||||
// Track which channels produced valid PMS5003T T/RH for SGP41 compensation.
|
||||
bool newPmsTempHumCh1 = false;
|
||||
bool newPmsTempHumCh2 = false;
|
||||
|
||||
// Read PMS channel 1 if available
|
||||
int channel = 1;
|
||||
// ---- Channel 1 ----
|
||||
if (configuration.hasSensorPMS1) {
|
||||
int channel = 1;
|
||||
if (ag->pms5003t_1.connected()) {
|
||||
measurements.update(Measurements::PM01, ag->pms5003t_1.getPm01Ae(), channel);
|
||||
measurements.update(Measurements::PM25, ag->pms5003t_1.getPm25Ae(), channel);
|
||||
@@ -1375,11 +1420,8 @@ static void updatePm(void) {
|
||||
measurements.update(Measurements::PM25_PC, ag->pms5003t_1.getPm25ParticleCount(), channel);
|
||||
measurements.update(Measurements::Temperature, ag->pms5003t_1.getTemperature(), channel);
|
||||
measurements.update(Measurements::Humidity, ag->pms5003t_1.getRelativeHumidity(), channel);
|
||||
|
||||
// flag that new valid PMS value exists
|
||||
newPMS1Value = true;
|
||||
newPmsTempHumCh1 = true;
|
||||
} else {
|
||||
// PMS channel 1 now is not connected, update using invalid value
|
||||
measurements.update(Measurements::PM01, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM25, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM10, utils::getInvalidPmValue(), channel);
|
||||
@@ -1393,11 +1435,13 @@ static void updatePm(void) {
|
||||
measurements.update(Measurements::Temperature, utils::getInvalidTemperature(), channel);
|
||||
measurements.update(Measurements::Humidity, utils::getInvalidHumidity(), channel);
|
||||
}
|
||||
} else if (configuration.hasSensorSPS30_1) {
|
||||
updateSPS30(ag->sps30_1, 1);
|
||||
}
|
||||
|
||||
// Read PMS channel 2 if available
|
||||
channel = 2;
|
||||
// ---- Channel 2 ----
|
||||
if (configuration.hasSensorPMS2) {
|
||||
int channel = 2;
|
||||
if (ag->pms5003t_2.connected()) {
|
||||
measurements.update(Measurements::PM01, ag->pms5003t_2.getPm01Ae(), channel);
|
||||
measurements.update(Measurements::PM25, ag->pms5003t_2.getPm25Ae(), channel);
|
||||
@@ -1411,11 +1455,8 @@ static void updatePm(void) {
|
||||
measurements.update(Measurements::PM25_PC, ag->pms5003t_2.getPm25ParticleCount(), channel);
|
||||
measurements.update(Measurements::Temperature, ag->pms5003t_2.getTemperature(), channel);
|
||||
measurements.update(Measurements::Humidity, ag->pms5003t_2.getRelativeHumidity(), channel);
|
||||
|
||||
// flag that new valid PMS value exists
|
||||
newPMS2Value = true;
|
||||
newPmsTempHumCh2 = true;
|
||||
} else {
|
||||
// PMS channel 2 now is not connected, update using invalid value
|
||||
measurements.update(Measurements::PM01, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM25, utils::getInvalidPmValue(), channel);
|
||||
measurements.update(Measurements::PM10, utils::getInvalidPmValue(), channel);
|
||||
@@ -1429,30 +1470,32 @@ static void updatePm(void) {
|
||||
measurements.update(Measurements::Temperature, utils::getInvalidTemperature(), channel);
|
||||
measurements.update(Measurements::Humidity, utils::getInvalidHumidity(), channel);
|
||||
}
|
||||
} else if (configuration.hasSensorSPS30_2) {
|
||||
updateSPS30(ag->sps30_2, 2);
|
||||
}
|
||||
|
||||
// SGP41 compensation — only uses T/RH from PMS5003T channels (SPS30 has no T/RH)
|
||||
if (configuration.hasSensorSGP) {
|
||||
float temp, hum;
|
||||
if (newPMS1Value && newPMS2Value) {
|
||||
// Both PMS has new valid value
|
||||
temp = (measurements.getFloat(Measurements::Temperature, 1) +
|
||||
measurements.getFloat(Measurements::Temperature, 2)) /
|
||||
2.0f;
|
||||
hum = (measurements.getFloat(Measurements::Humidity, 1) +
|
||||
measurements.getFloat(Measurements::Humidity, 2)) /
|
||||
2.0f;
|
||||
} else if (newPMS1Value) {
|
||||
// Only PMS1 has new valid value
|
||||
temp = measurements.getFloat(Measurements::Temperature, 1);
|
||||
hum = measurements.getFloat(Measurements::Humidity, 1);
|
||||
} else {
|
||||
// Only PMS2 has new valid value
|
||||
temp = measurements.getFloat(Measurements::Temperature, 2);
|
||||
hum = measurements.getFloat(Measurements::Humidity, 2);
|
||||
if (newPmsTempHumCh1 || newPmsTempHumCh2) {
|
||||
float temp, hum;
|
||||
if (newPmsTempHumCh1 && newPmsTempHumCh2) {
|
||||
temp = (measurements.getFloat(Measurements::Temperature, 1) +
|
||||
measurements.getFloat(Measurements::Temperature, 2)) /
|
||||
2.0f;
|
||||
hum = (measurements.getFloat(Measurements::Humidity, 1) +
|
||||
measurements.getFloat(Measurements::Humidity, 2)) /
|
||||
2.0f;
|
||||
} else if (newPmsTempHumCh1) {
|
||||
temp = measurements.getFloat(Measurements::Temperature, 1);
|
||||
hum = measurements.getFloat(Measurements::Humidity, 1);
|
||||
} else {
|
||||
temp = measurements.getFloat(Measurements::Temperature, 2);
|
||||
hum = measurements.getFloat(Measurements::Humidity, 2);
|
||||
}
|
||||
ag->sgp41.setCompensationTemperatureHumidity(temp, hum);
|
||||
}
|
||||
|
||||
// Update compensation temperature and humidity for SGP41
|
||||
ag->sgp41.setCompensationTemperatureHumidity(temp, hum);
|
||||
// When no PMS5003T channel provides T/RH (e.g. 2× SPS30), SGP41 keeps
|
||||
// its previous compensation values (default 25 °C / 50 %RH on first run).
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,14 +77,28 @@ String OpenMetrics::getPayload(void) {
|
||||
int nox = utils::getInvalidNOx();
|
||||
int noxRaw = utils::getInvalidNOx();
|
||||
|
||||
// Convenience flags: does this channel have any PM sensor?
|
||||
bool ch1HasPm = config.hasSensorPMS1 || config.hasSensorSPS30_1;
|
||||
bool ch2HasPm = config.hasSensorPMS2 || config.hasSensorSPS30_2;
|
||||
|
||||
// Get values
|
||||
if (config.hasSensorPMS1 && config.hasSensorPMS2) {
|
||||
_temp = (measure.getFloat(Measurements::Temperature, 1) +
|
||||
measure.getFloat(Measurements::Temperature, 2)) /
|
||||
2.0f;
|
||||
_hum = (measure.getFloat(Measurements::Humidity, 1) +
|
||||
measure.getFloat(Measurements::Humidity, 2)) /
|
||||
2.0f;
|
||||
if (ch1HasPm && ch2HasPm) {
|
||||
// Two PM channels — average T/RH only from PMS5003T channels
|
||||
if (config.hasSensorPMS1 && config.hasSensorPMS2) {
|
||||
_temp = (measure.getFloat(Measurements::Temperature, 1) +
|
||||
measure.getFloat(Measurements::Temperature, 2)) /
|
||||
2.0f;
|
||||
_hum = (measure.getFloat(Measurements::Humidity, 1) +
|
||||
measure.getFloat(Measurements::Humidity, 2)) /
|
||||
2.0f;
|
||||
} else if (config.hasSensorPMS1) {
|
||||
_temp = measure.getFloat(Measurements::Temperature, 1);
|
||||
_hum = measure.getFloat(Measurements::Humidity, 1);
|
||||
} else if (config.hasSensorPMS2) {
|
||||
_temp = measure.getFloat(Measurements::Temperature, 2);
|
||||
_hum = measure.getFloat(Measurements::Humidity, 2);
|
||||
}
|
||||
// PM values averaged across both channels regardless of brand
|
||||
pm01 = (measure.get(Measurements::PM01, 1) + measure.get(Measurements::PM01, 2)) / 2.0f;
|
||||
float correctedPm25_1 = measure.getCorrectedPM25(false, 1);
|
||||
float correctedPm25_2 = measure.getCorrectedPM25(false, 2);
|
||||
@@ -100,7 +114,7 @@ String OpenMetrics::getPayload(void) {
|
||||
_hum = measure.getFloat(Measurements::Humidity);
|
||||
}
|
||||
|
||||
if (config.hasSensorPMS1) {
|
||||
if (ch1HasPm) {
|
||||
pm01 = measure.get(Measurements::PM01);
|
||||
float correctedPm = measure.getCorrectedPM25(false, 1);
|
||||
pm25 = round(correctedPm);
|
||||
@@ -108,18 +122,23 @@ String OpenMetrics::getPayload(void) {
|
||||
pm03PCount = measure.get(Measurements::PM03_PC);
|
||||
}
|
||||
} else {
|
||||
if (config.hasSensorPMS1) {
|
||||
_temp = measure.getFloat(Measurements::Temperature, 1);
|
||||
_hum = measure.getFloat(Measurements::Humidity, 1);
|
||||
// Outdoor single-channel: T/RH only from PMS5003T, PM from any sensor
|
||||
if (ch1HasPm) {
|
||||
if (config.hasSensorPMS1) {
|
||||
_temp = measure.getFloat(Measurements::Temperature, 1);
|
||||
_hum = measure.getFloat(Measurements::Humidity, 1);
|
||||
}
|
||||
pm01 = measure.get(Measurements::PM01, 1);
|
||||
float correctedPm = measure.getCorrectedPM25(false, 1);
|
||||
pm25 = round(correctedPm);
|
||||
pm10 = measure.get(Measurements::PM10, 1);
|
||||
pm03PCount = measure.get(Measurements::PM03_PC, 1);
|
||||
}
|
||||
if (config.hasSensorPMS2) {
|
||||
_temp = measure.getFloat(Measurements::Temperature, 2);
|
||||
_hum = measure.getFloat(Measurements::Humidity, 2);
|
||||
if (ch2HasPm) {
|
||||
if (config.hasSensorPMS2) {
|
||||
_temp = measure.getFloat(Measurements::Temperature, 2);
|
||||
_hum = measure.getFloat(Measurements::Humidity, 2);
|
||||
}
|
||||
pm01 = measure.get(Measurements::PM01, 2);
|
||||
float correctedPm = measure.getCorrectedPM25(false, 2);
|
||||
pm25 = round(correctedPm);
|
||||
@@ -154,7 +173,7 @@ String OpenMetrics::getPayload(void) {
|
||||
}
|
||||
|
||||
// Add measurements that valid to the metrics
|
||||
if (config.hasSensorPMS1 || config.hasSensorPMS2) {
|
||||
if (ch1HasPm || ch2HasPm) {
|
||||
if (utils::isValidPm(pm01)) {
|
||||
add_metric("pm1",
|
||||
"PM1.0 concentration as measured by the AirGradient PMS "
|
||||
|
||||
+10
-2
@@ -19,6 +19,7 @@ const char *LED_BAR_MODE_NAMES[] = {
|
||||
[LedBarModeOff] = "off",
|
||||
[LedBarModePm] = "pm",
|
||||
[LedBarModeCO2] = "co2",
|
||||
[LedBarModeIaqs] = "iaqs",
|
||||
};
|
||||
|
||||
const char *PM_CORRECTION_ALGORITHM_NAMES[] = {
|
||||
@@ -112,6 +113,8 @@ String Configuration::getLedBarModeName(LedBarMode mode) {
|
||||
return String(LED_BAR_MODE_NAMES[LedBarModePm]);
|
||||
} else if (mode == LedBarModeCO2) {
|
||||
return String(LED_BAR_MODE_NAMES[LedBarModeCO2]);
|
||||
} else if (mode == LedBarModeIaqs) {
|
||||
return String(LED_BAR_MODE_NAMES[LedBarModeIaqs]);
|
||||
}
|
||||
return String("unknown");
|
||||
}
|
||||
@@ -740,7 +743,8 @@ bool Configuration::parse(String data, bool isLocal) {
|
||||
String mode = root[jprop_ledBarMode];
|
||||
if (mode == getLedBarModeName(LedBarMode::LedBarModeCO2) ||
|
||||
mode == getLedBarModeName(LedBarMode::LedBarModeOff) ||
|
||||
mode == getLedBarModeName(LedBarMode::LedBarModePm)) {
|
||||
mode == getLedBarModeName(LedBarMode::LedBarModePm) ||
|
||||
mode == getLedBarModeName(LedBarMode::LedBarModeIaqs)) {
|
||||
String oldMode = jconfig[jprop_ledBarMode];
|
||||
if (mode != oldMode) {
|
||||
jconfig[jprop_ledBarMode] = mode;
|
||||
@@ -1190,6 +1194,9 @@ LedBarMode Configuration::getLedBarMode(void) {
|
||||
if (mode == getLedBarModeName(LedBarModePm)) {
|
||||
return LedBarModePm;
|
||||
}
|
||||
if (mode == getLedBarModeName(LedBarModeIaqs)) {
|
||||
return LedBarModeIaqs;
|
||||
}
|
||||
return LedBarModeOff;
|
||||
}
|
||||
|
||||
@@ -1398,7 +1405,8 @@ void Configuration::toConfig(const char *buf) {
|
||||
String mode = jconfig[jprop_ledBarMode];
|
||||
if (mode != getLedBarModeName(LedBarMode::LedBarModeCO2) &&
|
||||
mode != getLedBarModeName(LedBarMode::LedBarModeOff) &&
|
||||
mode != getLedBarModeName(LedBarMode::LedBarModePm)) {
|
||||
mode != getLedBarModeName(LedBarMode::LedBarModePm) &&
|
||||
mode != getLedBarModeName(LedBarMode::LedBarModeIaqs)) {
|
||||
isConfigFieldInvalid = true;
|
||||
} else {
|
||||
isConfigFieldInvalid = false;
|
||||
|
||||
+4
-2
@@ -75,8 +75,10 @@ public:
|
||||
bool hasSensorS8 = true;
|
||||
bool hasSensorPMS1 = true;
|
||||
bool hasSensorPMS2 = true;
|
||||
bool hasSensorSPS30 =
|
||||
false; ///< SPS30 detected as PMS alternative (auto-detect)
|
||||
bool hasSensorSPS30_1 =
|
||||
false; ///< SPS30 detected on PM channel 1 (auto-detect)
|
||||
bool hasSensorSPS30_2 =
|
||||
false; ///< SPS30 detected on PM channel 2 (auto-detect)
|
||||
bool hasSensorSGP = true;
|
||||
bool hasSensorSHT = true;
|
||||
|
||||
|
||||
+91
-18
@@ -1,5 +1,6 @@
|
||||
#include "AgOledDisplay.h"
|
||||
#include "Libraries/U8g2/src/U8g2lib.h"
|
||||
#include "Main/GoIaqs.h"
|
||||
#include "Main/utils.h"
|
||||
|
||||
/** Cast U8G2 */
|
||||
@@ -411,28 +412,100 @@ void OledDisplay::showDashboard(DashboardStatus status) {
|
||||
DISP()->drawUTF8(55, 61, "ug/m³");
|
||||
}
|
||||
|
||||
/** Draw tvocIndexlabel */
|
||||
DISP()->setFont(u8g2_font_t0_12_tf);
|
||||
DISP()->drawStr(100, 27, "VOC:");
|
||||
if (config.getLedBarMode() == LedBarMode::LedBarModeIaqs) {
|
||||
/** Draw IAQS panel (column 3 replaces VOC/NOx when LED bar mode
|
||||
* is set to iaqs). Three rows aligned with the CO2/PM2.5 column
|
||||
* rhythm: header (y=27), big score + grade letter (y=48),
|
||||
* dominant pollutant (y=61). */
|
||||
const int IAQS_COL_X = 100;
|
||||
const int IAQS_COL_W = 28;
|
||||
const int SCORE_GRADE_GAP = 1;
|
||||
const int ARROW_DOM_GAP = 2;
|
||||
const int ARROW_DOM_BOTH_GAP = 1;
|
||||
DISP()->setFont(u8g2_font_t0_12_tf);
|
||||
DISP()->drawStr(IAQS_COL_X, 27, "IAQS");
|
||||
|
||||
/** Draw tvocIndexvalue */
|
||||
int tvoc = round(value.getAverage(Measurements::TVOC));
|
||||
if (utils::isValidVOC(tvoc)) {
|
||||
sprintf(strBuf, "%d", tvoc);
|
||||
} else {
|
||||
sprintf(strBuf, "%s", "-");
|
||||
}
|
||||
DISP()->drawStr(100, 39, strBuf);
|
||||
float pm25Avg = value.getAverage(Measurements::PM25);
|
||||
if (config.hasSensorSHT && config.isPMCorrectionEnabled()) {
|
||||
pm25Avg = value.getCorrectedPM25(true);
|
||||
}
|
||||
float co2Avg = value.getAverage(Measurements::CO2);
|
||||
bool pmOk = utils::isValidPm((int)round(pm25Avg));
|
||||
bool coOk = utils::isValidCO2((int)round(co2Avg));
|
||||
|
||||
/** Draw NOx label */
|
||||
int nox = round(value.getAverage(Measurements::NOx));
|
||||
DISP()->drawStr(100, 53, "NOx:");
|
||||
if (utils::isValidNOx(nox)) {
|
||||
sprintf(strBuf, "%d", nox);
|
||||
if (!pmOk || !coOk) {
|
||||
DISP()->setFont(u8g2_font_t0_22b_tf);
|
||||
int scoreW = DISP()->getStrWidth("-");
|
||||
DISP()->drawStr(IAQS_COL_X + ((IAQS_COL_W - scoreW) / 2), 48, "-");
|
||||
} else {
|
||||
int pmScore = GoIaqs::pm25Score(pm25Avg);
|
||||
int coScore = GoIaqs::co2Score(co2Avg);
|
||||
int totalIaqs = GoIaqs::totalScore(pmScore, coScore);
|
||||
GoIaqs::Category cat = GoIaqs::categoryOf(totalIaqs);
|
||||
GoIaqs::Dominant dom = GoIaqs::dominantOf(pmScore, coScore);
|
||||
|
||||
/** Row 2: big score + letter grade centered in the IAQS column. */
|
||||
sprintf(strBuf, "%d", totalIaqs);
|
||||
DISP()->setFont(u8g2_font_t0_22b_tf);
|
||||
int scoreW = DISP()->getStrWidth(strBuf);
|
||||
|
||||
char gradeStr[2] = {GoIaqs::letterOf(cat), '\0'};
|
||||
DISP()->setFont(u8g2_font_t0_12_tf);
|
||||
int gradeW = DISP()->getStrWidth(gradeStr);
|
||||
|
||||
int scoreX = IAQS_COL_X +
|
||||
((IAQS_COL_W - scoreW - SCORE_GRADE_GAP - gradeW) / 2);
|
||||
DISP()->setFont(u8g2_font_t0_22b_tf);
|
||||
DISP()->drawStr(scoreX, 48, strBuf);
|
||||
DISP()->setFont(u8g2_font_t0_12_tf);
|
||||
DISP()->drawStr(scoreX + scoreW + SCORE_GRADE_GAP, 48, gradeStr);
|
||||
|
||||
/** Row 3: dominant pollutant. */
|
||||
const char *domStr = "BOTH";
|
||||
if (dom == GoIaqs::DominantPm25) {
|
||||
domStr = "PM";
|
||||
} else if (dom == GoIaqs::DominantCo2) {
|
||||
domStr = "CO2";
|
||||
}
|
||||
DISP()->setFont(u8g2_font_t0_12_tf);
|
||||
const int ARROW_W = 5;
|
||||
int arrowDomGap = (dom == GoIaqs::DominantBoth) ? ARROW_DOM_BOTH_GAP
|
||||
: ARROW_DOM_GAP;
|
||||
int domW = DISP()->getStrWidth(domStr);
|
||||
int domX = IAQS_COL_X +
|
||||
((IAQS_COL_W - ARROW_W - arrowDomGap - domW) / 2);
|
||||
int arrowY = 57;
|
||||
DISP()->drawLine(domX, arrowY, domX + ARROW_W, arrowY);
|
||||
DISP()->drawLine(domX + ARROW_W, arrowY, domX + ARROW_W - 2,
|
||||
arrowY - 2);
|
||||
DISP()->drawLine(domX + ARROW_W, arrowY, domX + ARROW_W - 2,
|
||||
arrowY + 2);
|
||||
DISP()->drawStr(domX + ARROW_W + arrowDomGap, 61, domStr);
|
||||
}
|
||||
} else {
|
||||
sprintf(strBuf, "%s", "-");
|
||||
/** Draw tvocIndexlabel */
|
||||
DISP()->setFont(u8g2_font_t0_12_tf);
|
||||
DISP()->drawStr(100, 27, "VOC:");
|
||||
|
||||
/** Draw tvocIndexvalue */
|
||||
int tvoc = round(value.getAverage(Measurements::TVOC));
|
||||
if (utils::isValidVOC(tvoc)) {
|
||||
sprintf(strBuf, "%d", tvoc);
|
||||
} else {
|
||||
sprintf(strBuf, "%s", "-");
|
||||
}
|
||||
DISP()->drawStr(100, 39, strBuf);
|
||||
|
||||
/** Draw NOx label */
|
||||
int nox = round(value.getAverage(Measurements::NOx));
|
||||
DISP()->drawStr(100, 53, "NOx:");
|
||||
if (utils::isValidNOx(nox)) {
|
||||
sprintf(strBuf, "%d", nox);
|
||||
} else {
|
||||
sprintf(strBuf, "%s", "-");
|
||||
}
|
||||
DISP()->drawStr(100, 63, strBuf);
|
||||
}
|
||||
DISP()->drawStr(100, 63, strBuf);
|
||||
} while (DISP()->nextPage());
|
||||
} else if (ag->isBasic()) {
|
||||
ag->display.clear();
|
||||
|
||||
+75
-6
@@ -1,5 +1,6 @@
|
||||
#include "AgStateMachine.h"
|
||||
#include "AgOledDisplay.h"
|
||||
#include "Main/GoIaqs.h"
|
||||
|
||||
#define LED_TEST_BLINK_DELAY 50 /** ms */
|
||||
#define LED_FAST_BLINK_DELAY 250 /** ms */
|
||||
@@ -62,6 +63,9 @@ bool StateMachine::sensorhandleLeds(void) {
|
||||
case LedBarMode::LedBarModePm:
|
||||
totalLedUsed = pm25handleLeds();
|
||||
break;
|
||||
case LedBarMode::LedBarModeIaqs:
|
||||
totalLedUsed = iaqsHandleLeds();
|
||||
break;
|
||||
default:
|
||||
ag->ledBar.clear();
|
||||
break;
|
||||
@@ -267,6 +271,54 @@ int StateMachine::pm25handleLeds(void) {
|
||||
return totalUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Show GO IAQS Starter Score LED status.
|
||||
*
|
||||
* Computes the score from PM2.5 (corrected when enabled) and CO2 averages,
|
||||
* then lights a number of LEDs (from the right end) proportional to the
|
||||
* severity, all painted with the category color (Good/Moderate/Unhealthy).
|
||||
*
|
||||
* Mapping is 1:1 between LEDs and score: ledsLit = numLeds - totalScore,
|
||||
* so score 10 lights 1 LED (LED 10) and score 0 lights all 11 LEDs.
|
||||
* Connectivity-status overlays (WiFiLost, ServerLost, SensorConfigFailed)
|
||||
* are handled at the dispatcher level for IAQS mode: the entire bar is
|
||||
* turned off and only LED 0 is lit with the notification color, so the
|
||||
* notification is always unambiguous regardless of the IAQS score.
|
||||
*
|
||||
* @return number of LEDs used on the bar.
|
||||
*/
|
||||
int StateMachine::iaqsHandleLeds(void) {
|
||||
float pm25Value = value.getAverage(Measurements::PM25);
|
||||
if (config.hasSensorSHT && config.isPMCorrectionEnabled()) {
|
||||
pm25Value = value.getCorrectedPM25(true);
|
||||
}
|
||||
float co2Value = value.getAverage(Measurements::CO2);
|
||||
|
||||
int pmScore = GoIaqs::pm25Score(pm25Value);
|
||||
int coScore = GoIaqs::co2Score(co2Value);
|
||||
int total = GoIaqs::totalScore(pmScore, coScore);
|
||||
GoIaqs::Rgb color = GoIaqs::colorOf(GoIaqs::categoryOf(total));
|
||||
|
||||
int numLeds = ag->ledBar.getNumberOfLeds();
|
||||
/** 1:1 LED-per-score mapping: worse score -> more LEDs lit. */
|
||||
int ledsLit = numLeds - total;
|
||||
if (ledsLit < 1) {
|
||||
ledsLit = 1;
|
||||
}
|
||||
if (ledsLit > numLeds) {
|
||||
ledsLit = numLeds;
|
||||
}
|
||||
|
||||
for (int i = 0; i < ledsLit; i++) {
|
||||
ag->ledBar.setColor(color.r, color.g, color.b, numLeds - 1 - i);
|
||||
}
|
||||
|
||||
Serial.printf("GO IAQS = %d [pm25 score %d, co2 score %d, leds %d]\n", total,
|
||||
pmScore, coScore, ledsLit);
|
||||
|
||||
return ledsLit;
|
||||
}
|
||||
|
||||
void StateMachine::co2Calibration(void) {
|
||||
if (config.isCo2CalibrationRequested() && config.hasSensorS8) {
|
||||
logInfo("CO2 Calibration");
|
||||
@@ -764,9 +816,16 @@ void StateMachine::handleLeds(AgStateMachineState state) {
|
||||
/** Connection to WiFi network failed credentials incorrect encryption not
|
||||
* supported etc. */
|
||||
if (ag->isOne()) {
|
||||
bool allUsed = sensorhandleLeds();
|
||||
if (allUsed == false) {
|
||||
if (config.getLedBarMode() == LedBarMode::LedBarModeIaqs) {
|
||||
/** IAQS mode: suppress the score bar so only the notification LED
|
||||
* is visible. */
|
||||
ag->ledBar.clear();
|
||||
ag->ledBar.setColor(255, 0, 0, 0);
|
||||
} else {
|
||||
bool allUsed = sensorhandleLeds();
|
||||
if (allUsed == false) {
|
||||
ag->ledBar.setColor(255, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ag->statusLed.setOff();
|
||||
@@ -777,9 +836,14 @@ void StateMachine::handleLeds(AgStateMachineState state) {
|
||||
/** Connected to WiFi network but the server cannot be reached through the
|
||||
* internet, e.g. blocked by firewall */
|
||||
if (ag->isOne()) {
|
||||
bool allUsed = sensorhandleLeds();
|
||||
if (allUsed == false) {
|
||||
if (config.getLedBarMode() == LedBarMode::LedBarModeIaqs) {
|
||||
ag->ledBar.clear();
|
||||
ag->ledBar.setColor(233, 183, 54, 0);
|
||||
} else {
|
||||
bool allUsed = sensorhandleLeds();
|
||||
if (allUsed == false) {
|
||||
ag->ledBar.setColor(233, 183, 54, 0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ag->statusLed.setOff();
|
||||
@@ -790,9 +854,14 @@ void StateMachine::handleLeds(AgStateMachineState state) {
|
||||
/** Server is reachable but there is some configuration issue to be fixed on
|
||||
* the server side */
|
||||
if (ag->isOne()) {
|
||||
bool allUsed = sensorhandleLeds();
|
||||
if (allUsed == false) {
|
||||
if (config.getLedBarMode() == LedBarMode::LedBarModeIaqs) {
|
||||
ag->ledBar.clear();
|
||||
ag->ledBar.setColor(139, 24, 248, 0);
|
||||
} else {
|
||||
bool allUsed = sensorhandleLeds();
|
||||
if (allUsed == false) {
|
||||
ag->ledBar.setColor(139, 24, 248, 0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ag->statusLed.setOff();
|
||||
|
||||
@@ -27,6 +27,7 @@ private:
|
||||
bool sensorhandleLeds(void);
|
||||
int co2handleLeds(void);
|
||||
int pm25handleLeds(void);
|
||||
int iaqsHandleLeds(void);
|
||||
void co2Calibration(void);
|
||||
void ledBarTest(void);
|
||||
void ledBarPowerUpTest(void);
|
||||
|
||||
+25
-23
@@ -125,7 +125,7 @@ void Measurements::printCurrentAverage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.hasSensorPMS1 || config.hasSensorSPS30) {
|
||||
if (config.hasSensorPMS1 || config.hasSensorSPS30_1) {
|
||||
printCurrentPMAverage(1);
|
||||
if (!config.hasSensorSHT) {
|
||||
if (utils::isValidTemperature(_temperature[0].update.avg)) {
|
||||
@@ -140,7 +140,7 @@ void Measurements::printCurrentAverage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.hasSensorPMS2) {
|
||||
if (config.hasSensorPMS2 || config.hasSensorSPS30_2) {
|
||||
printCurrentPMAverage(2);
|
||||
if (!config.hasSensorSHT) {
|
||||
if (utils::isValidTemperature(_temperature[1].update.avg)) {
|
||||
@@ -1161,38 +1161,39 @@ String Measurements::toString(bool localServer, AgFirmwareMode fwMode, int rssi)
|
||||
|
||||
JSONVar Measurements::buildOutdoor(bool localServer, AgFirmwareMode fwMode) {
|
||||
JSONVar outdoor;
|
||||
bool ch1HasPm = config.hasSensorPMS1 || config.hasSensorSPS30_1;
|
||||
bool ch2HasPm = config.hasSensorPMS2 || config.hasSensorSPS30_2;
|
||||
|
||||
if (fwMode == FW_MODE_O_1P || fwMode == FW_MODE_O_1PS || fwMode == FW_MODE_O_1PST) {
|
||||
// buildPMS params:
|
||||
/// Because only have 1 PMS, allCh is set to false
|
||||
/// But enable temp hum from PMS
|
||||
/// compensated values if requested by local server
|
||||
/// Set ch based on hasSensorPMSx
|
||||
if (config.hasSensorPMS1) {
|
||||
// Single PM channel — pick whichever channel is populated
|
||||
// Enable temp/hum from PMS; compensated values if requested by local server
|
||||
if (ch1HasPm) {
|
||||
outdoor = buildPMS(1, false, true, localServer);
|
||||
if (!localServer) {
|
||||
// Firmware version only available for PMS5003T
|
||||
if (!localServer && config.hasSensorPMS1) {
|
||||
outdoor[json_prop_pmFirmware] =
|
||||
pms5003TFirmwareVersion(ag->pms5003t_1.getFirmwareVersion());
|
||||
}
|
||||
} else {
|
||||
} else if (ch2HasPm) {
|
||||
outdoor = buildPMS(2, false, true, localServer);
|
||||
if (!localServer) {
|
||||
if (!localServer && config.hasSensorPMS2) {
|
||||
outdoor[json_prop_pmFirmware] =
|
||||
pms5003TFirmwareVersion(ag->pms5003t_2.getFirmwareVersion());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// FW_MODE_O_1PPT && FW_MODE_O_1PP: Outdoor monitor that have 2 PMS sensor
|
||||
// buildPMS params:
|
||||
/// Have 2 PMS sensor, allCh is set to true (ch params ignored)
|
||||
/// Enable temp hum from PMS
|
||||
/// compensated values if requested by local server
|
||||
// FW_MODE_O_1PPT / FW_MODE_O_1PP: two PM channels, average via allCh
|
||||
outdoor = buildPMS(1, true, true, localServer);
|
||||
// PMS5003T version
|
||||
// Per-channel firmware version — only for PMS5003T channels
|
||||
if (!localServer) {
|
||||
outdoor["channels"]["1"][json_prop_pmFirmware] =
|
||||
pms5003TFirmwareVersion(ag->pms5003t_1.getFirmwareVersion());
|
||||
outdoor["channels"]["2"][json_prop_pmFirmware] =
|
||||
pms5003TFirmwareVersion(ag->pms5003t_2.getFirmwareVersion());
|
||||
if (config.hasSensorPMS1) {
|
||||
outdoor["channels"]["1"][json_prop_pmFirmware] =
|
||||
pms5003TFirmwareVersion(ag->pms5003t_1.getFirmwareVersion());
|
||||
}
|
||||
if (config.hasSensorPMS2) {
|
||||
outdoor["channels"]["2"][json_prop_pmFirmware] =
|
||||
pms5003TFirmwareVersion(ag->pms5003t_2.getFirmwareVersion());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,7 +1203,7 @@ JSONVar Measurements::buildOutdoor(bool localServer, AgFirmwareMode fwMode) {
|
||||
JSONVar Measurements::buildIndoor(bool localServer) {
|
||||
JSONVar indoor;
|
||||
|
||||
if (config.hasSensorPMS1 || config.hasSensorSPS30) {
|
||||
if (config.hasSensorPMS1 || config.hasSensorSPS30_1) {
|
||||
// buildPMS params:
|
||||
/// PMS channel 1 (indoor only have 1 PMS; hence allCh false)
|
||||
/// Not include temperature and humidity from PMS sensor
|
||||
@@ -1210,7 +1211,8 @@ JSONVar Measurements::buildIndoor(bool localServer) {
|
||||
indoor = buildPMS(1, false, false, true);
|
||||
if (!localServer && config.hasSensorPMS1) {
|
||||
// PMS firmware version only available for PMS5003
|
||||
indoor[json_prop_pmFirmware] = this->pms5003FirmwareVersion(ag->pms5003.getFirmwareVersion());
|
||||
indoor[json_prop_pmFirmware] =
|
||||
this->pms5003FirmwareVersion(ag->pms5003.getFirmwareVersion());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -6,9 +6,9 @@
|
||||
#endif
|
||||
|
||||
AirGradient::AirGradient(BoardType type)
|
||||
: pms5003(type), pms5003t_1(type), pms5003t_2(type), sps30(type), s8(type),
|
||||
sgp41(type), display(type), boardType(type), button(type),
|
||||
statusLed(type), ledBar(type), watchdog(type), sht(type) {}
|
||||
: pms5003(type), pms5003t_1(type), pms5003t_2(type), sps30_1(type),
|
||||
sps30_2(type), s8(type), sgp41(type), display(type), boardType(type),
|
||||
button(type), statusLed(type), ledBar(type), watchdog(type), sht(type) {}
|
||||
|
||||
/**
|
||||
* @brief Get pin number for I2C SDA
|
||||
|
||||
+9
-3
@@ -82,10 +82,16 @@ public:
|
||||
PMS5003T pms5003t_2;
|
||||
|
||||
/**
|
||||
* @brief Sensirion SPS30 particulate matter sensor (UART).
|
||||
* Used as alternative to PMS5003 on ONE_INDOOR via auto-detection.
|
||||
* @brief Sensirion SPS30 particulate matter sensor (UART), channel 1.
|
||||
* Used as alternative PM sensor via auto-detection on Serial0.
|
||||
*/
|
||||
SPS30 sps30;
|
||||
SPS30 sps30_1;
|
||||
|
||||
/**
|
||||
* @brief Sensirion SPS30 particulate matter sensor (UART), channel 2.
|
||||
* Used on OPEN_AIR_OUTDOOR when a second SPS30 is detected.
|
||||
*/
|
||||
SPS30 sps30_2;
|
||||
|
||||
/**
|
||||
* @brief SenseAirS8 CO2 sensor
|
||||
|
||||
@@ -81,6 +81,9 @@ enum LedBarMode {
|
||||
|
||||
/** Use LED bar for show CO2 value level */
|
||||
LedBarModeCO2,
|
||||
|
||||
/** Use LED bar to show GO IAQS Starter Score (PM2.5 + CO2) */
|
||||
LedBarModeIaqs,
|
||||
};
|
||||
|
||||
enum ConfigurationControl {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#include "GoIaqs.h"
|
||||
|
||||
#include <math.h>
|
||||
|
||||
namespace {
|
||||
|
||||
struct Anchor {
|
||||
float x;
|
||||
int y;
|
||||
};
|
||||
|
||||
/** White-paper PM2.5 anchors (ug/m^3, score). */
|
||||
const Anchor PM25_ANCHORS[] = {
|
||||
{0.0f, 10}, {10.0f, 8}, {11.0f, 7}, {25.0f, 4}, {26.0f, 3}, {100.0f, 0},
|
||||
};
|
||||
|
||||
/** White-paper CO2 anchors (ppm, score). */
|
||||
const Anchor CO2_ANCHORS[] = {
|
||||
{400.0f, 10}, {800.0f, 8}, {801.0f, 7},
|
||||
{1400.0f, 4}, {1401.0f, 3}, {5000.0f, 0},
|
||||
};
|
||||
|
||||
const float PM25_MIN = 0.0f;
|
||||
const float PM25_MAX = 100.0f;
|
||||
const float CO2_MIN = 400.0f;
|
||||
const float CO2_MAX = 5000.0f;
|
||||
|
||||
inline float clampF(float v, float lo, float hi) {
|
||||
if (v < lo) {
|
||||
return lo;
|
||||
}
|
||||
if (v > hi) {
|
||||
return hi;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
inline int clampI(int v, int lo, int hi) {
|
||||
if (v < lo) {
|
||||
return lo;
|
||||
}
|
||||
if (v > hi) {
|
||||
return hi;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Round-half-up (matching the reference TS implementation). */
|
||||
inline int roundHalfUp(float v) { return (int)floorf(v + 0.5f); }
|
||||
|
||||
int interpolate(float value, const Anchor *anchors, size_t count) {
|
||||
if (value <= anchors[0].x) {
|
||||
return anchors[0].y;
|
||||
}
|
||||
const Anchor &last = anchors[count - 1];
|
||||
if (value >= last.x) {
|
||||
return last.y;
|
||||
}
|
||||
for (size_t i = 0; i < count - 1; i++) {
|
||||
const Anchor &left = anchors[i];
|
||||
const Anchor &right = anchors[i + 1];
|
||||
if (value <= right.x) {
|
||||
float interp =
|
||||
(float)left.y +
|
||||
((value - left.x) * (float)(right.y - left.y)) / (right.x - left.x);
|
||||
return clampI(roundHalfUp(interp), GoIaqs::SCORE_MIN, GoIaqs::SCORE_MAX);
|
||||
}
|
||||
}
|
||||
return GoIaqs::SCORE_MIN;
|
||||
}
|
||||
|
||||
int pollutantScore(float value, float min, float max, const Anchor *anchors,
|
||||
size_t count) {
|
||||
if (!isfinite(value) || value > max) {
|
||||
return GoIaqs::SCORE_MIN;
|
||||
}
|
||||
return interpolate(clampF(value, min, max), anchors, count);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int GoIaqs::pm25Score(float pm25UgM3) {
|
||||
return pollutantScore(pm25UgM3, PM25_MIN, PM25_MAX, PM25_ANCHORS,
|
||||
sizeof(PM25_ANCHORS) / sizeof(PM25_ANCHORS[0]));
|
||||
}
|
||||
|
||||
int GoIaqs::co2Score(float co2Ppm) {
|
||||
return pollutantScore(co2Ppm, CO2_MIN, CO2_MAX, CO2_ANCHORS,
|
||||
sizeof(CO2_ANCHORS) / sizeof(CO2_ANCHORS[0]));
|
||||
}
|
||||
|
||||
int GoIaqs::totalScore(int pm25, int co2) {
|
||||
if (pm25 == co2) {
|
||||
if (pm25 <= 7) {
|
||||
int reduced = pm25 - 1;
|
||||
return reduced < SCORE_MIN ? SCORE_MIN : reduced;
|
||||
}
|
||||
return pm25;
|
||||
}
|
||||
return pm25 < co2 ? pm25 : co2;
|
||||
}
|
||||
|
||||
GoIaqs::Category GoIaqs::categoryOf(int totalScore) {
|
||||
if (totalScore >= 8) {
|
||||
return CategoryGood;
|
||||
}
|
||||
if (totalScore >= 4) {
|
||||
return CategoryModerate;
|
||||
}
|
||||
return CategoryUnhealthy;
|
||||
}
|
||||
|
||||
GoIaqs::Rgb GoIaqs::colorOf(Category category) {
|
||||
switch (category) {
|
||||
case CategoryGood:
|
||||
return Rgb{0x64, 0x8E, 0xFF};
|
||||
case CategoryModerate:
|
||||
return Rgb{255, 128, 0};
|
||||
case CategoryUnhealthy:
|
||||
default:
|
||||
return Rgb{255, 0, 0};
|
||||
}
|
||||
}
|
||||
|
||||
GoIaqs::Dominant GoIaqs::dominantOf(int pm25Score, int co2Score) {
|
||||
if (pm25Score == co2Score) {
|
||||
return DominantBoth;
|
||||
}
|
||||
return (pm25Score < co2Score) ? DominantPm25 : DominantCo2;
|
||||
}
|
||||
|
||||
char GoIaqs::letterOf(Category category) {
|
||||
switch (category) {
|
||||
case CategoryGood:
|
||||
return 'A';
|
||||
case CategoryModerate:
|
||||
return 'B';
|
||||
case CategoryUnhealthy:
|
||||
default:
|
||||
return 'Z';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
#ifndef _GO_IAQS_H_
|
||||
#define _GO_IAQS_H_
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/**
|
||||
* @brief GO IAQS Starter Score implementation.
|
||||
*
|
||||
* Reference:
|
||||
* https://www.airgradient.com/blog/go-iaqs-starter-score-technical-implementation
|
||||
* Attribution: Achim Haug (AirGradient) for GO AQS. License: CC BY-SA 4.0.
|
||||
*
|
||||
* Converts PM2.5 (ug/m^3) and CO2 (ppm) concentrations into an integer
|
||||
* "0..10" total score plus category and category color, using piecewise
|
||||
* linear anchors and the synergetic combination rule defined in the
|
||||
* white paper.
|
||||
*
|
||||
* Pure computation; no Arduino / hardware dependencies.
|
||||
*/
|
||||
class GoIaqs {
|
||||
public:
|
||||
enum Category {
|
||||
CategoryGood = 0, /** total score 8..10, color #648EFF */
|
||||
CategoryModerate = 1, /** total score 4..7, color #FFB000 */
|
||||
CategoryUnhealthy = 2, /** total score 0..3, color #FF190C */
|
||||
};
|
||||
|
||||
enum Dominant {
|
||||
DominantPm25 = 0, /** PM2.5 has the worse per-pollutant score */
|
||||
DominantCo2 = 1, /** CO2 has the worse per-pollutant score */
|
||||
DominantBoth = 2, /** PM2.5 and CO2 per-pollutant scores are equal */
|
||||
};
|
||||
|
||||
struct Rgb {
|
||||
uint8_t r;
|
||||
uint8_t g;
|
||||
uint8_t b;
|
||||
};
|
||||
|
||||
/** Score bounds (inclusive). */
|
||||
static const int SCORE_MIN = 0;
|
||||
static const int SCORE_MAX = 10;
|
||||
|
||||
/**
|
||||
* @brief Compute per-pollutant score for PM2.5.
|
||||
*
|
||||
* @param pm25UgM3 PM2.5 concentration in ug/m^3.
|
||||
* @return int in [SCORE_MIN .. SCORE_MAX]
|
||||
*/
|
||||
static int pm25Score(float pm25UgM3);
|
||||
|
||||
/**
|
||||
* @brief Compute per-pollutant score for CO2.
|
||||
*
|
||||
* @param co2Ppm CO2 concentration in ppm.
|
||||
* @return int in [SCORE_MIN .. SCORE_MAX]
|
||||
*/
|
||||
static int co2Score(float co2Ppm);
|
||||
|
||||
/**
|
||||
* @brief Combine two per-pollutant scores into a single total score
|
||||
* applying the synergetic rule:
|
||||
* - If scores differ: total = min(pm25, co2).
|
||||
* - If equal and shared <= 7: total = max(shared - 1, 0).
|
||||
* - If equal and shared >= 8: total = shared.
|
||||
*/
|
||||
static int totalScore(int pm25, int co2);
|
||||
|
||||
/**
|
||||
* @brief Map a total score to a category (Good / Moderate / Unhealthy).
|
||||
*/
|
||||
static Category categoryOf(int totalScore);
|
||||
|
||||
/**
|
||||
* @brief Get the RGB color associated with a category, tuned for the
|
||||
* LED bar (may differ from the white-paper hex values to match the
|
||||
* physical LED output).
|
||||
*/
|
||||
static Rgb colorOf(Category category);
|
||||
|
||||
/**
|
||||
* @brief Identify the pollutant that drives the total score. The lower
|
||||
* per-pollutant score wins; equal scores return DominantBoth.
|
||||
*/
|
||||
static Dominant dominantOf(int pm25Score, int co2Score);
|
||||
|
||||
/**
|
||||
* @brief Get the single-character letter grade for a category, per the
|
||||
* white paper: Good -> 'A', Moderate -> 'B', Unhealthy -> 'Z'.
|
||||
*/
|
||||
static char letterOf(Category category);
|
||||
};
|
||||
|
||||
#endif /** _GO_IAQS_H_ */
|
||||
+21
-1
@@ -17,13 +17,33 @@ bool SPS30::begin(HardwareSerial &serial) {
|
||||
return true;
|
||||
}
|
||||
|
||||
_bsp = getBoardDef(_boardDef);
|
||||
if (_bsp == nullptr) {
|
||||
AgLog("Board [%d] not supported", _boardDef);
|
||||
return false;
|
||||
}
|
||||
|
||||
_serial = &serial;
|
||||
|
||||
// Fully reset the serial port — it may have been left at 9600 baud
|
||||
// with stale buffer data from a failed PMS5003 detection attempt.
|
||||
_serial->end();
|
||||
delay(100);
|
||||
_serial->begin(115200);
|
||||
|
||||
// Serial0 (UART0) defaults map to the correct PM connector pins.
|
||||
// Serial1 shares the S8 connector pins and needs explicit GPIO mapping,
|
||||
// same as PMS5003T::begin() does.
|
||||
#if ARDUINO_USB_CDC_ON_BOOT
|
||||
if (_serial == &Serial0) {
|
||||
#else
|
||||
if (_serial == &Serial) {
|
||||
#endif
|
||||
_serial->begin(115200);
|
||||
} else {
|
||||
_serial->begin(115200, SERIAL_8N1, _bsp->SenseAirS8.uart_rx_pin,
|
||||
_bsp->SenseAirS8.uart_tx_pin);
|
||||
}
|
||||
|
||||
// Flush any garbage bytes left in the RX buffer
|
||||
while (_serial->available()) {
|
||||
_serial->read();
|
||||
|
||||
@@ -107,6 +107,7 @@ private:
|
||||
bool _connected = false;
|
||||
int _consecutiveErrors = 0;
|
||||
BoardType _boardDef;
|
||||
const BoardDef *_bsp = nullptr;
|
||||
SensirionUartSps30 _driver;
|
||||
HardwareSerial *_serial = nullptr;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user