Compare commits

...

8 Commits

Author SHA1 Message Date
Samuel Siburian d7529ccb89 Merge pull request #375 from airgradienthq/feat/sps30-ooa
Add SPS30 PM sensor support for OPEN_AIR_OUTDOOR
2026-05-27 09:31:20 +04:00
samuelbles07 783b765df6 fix(sps30): use explicit GPIO pins for Serial1
Serial1 on ESP32-C3 defaults to wrong GPIOs. SPS30 wrapper
called begin(115200) without pin args, so Serial1 mapped to
floating pins instead of GPIO 0/1 (S8 connector).

Mirror PMS5003T::begin() pattern: Serial0 uses UART0 defaults,
Serial1 uses SenseAirS8 rx/tx pins from BoardDef.
2026-05-26 11:14:44 +04:00
samuelbles07 d47331c9de feat(outdoor): add SPS30 PM sensor support for OPEN_AIR_OUTDOOR
Indoor SPS30 support (a5e3ea2) only covered ONE_INDOOR.
Outdoor needs 1×SPS30, 2×SPS30, or SPS30+PMS5003T mixed configs.

- Rename hasSensorSPS30 → hasSensorSPS30_1, add hasSensorSPS30_2
- Rename sps30 → sps30_1, add sps30_2 instance
- Parameterize updateSPS30() by sensor ref + channel
- Per-port outdoor detection: PMS5003T first, SPS30 fallback
- SGP41 compensation only from PMS5003T T/RH channels;
  skipped entirely for 2×SPS30 (uses default 25°C/50%RH)
- buildOutdoor/OpenMetrics: firmware version field only for
  PMS5003T channels, PM gating broadened for SPS30
2026-05-25 12:25:25 +04:00
Samuel Siburian 1906cc1606 Merge pull request #374 from airgradienthq/feat/go-iaqs
feat(led): add IAQS score LED bar mode
2026-05-22 15:53:17 +04:00
samuelbles07 2829e1f5a8 fix(oled): center IAQS details 2026-05-22 15:34:45 +04:00
samuelbles07 7068ede0fc feat(oled): show IAQS score panel 2026-05-21 00:27:02 +04:00
samuelbles07 733ddf17ef feat(led): 1:1 IAQS score mapping with notification override
- iaqsHandleLeds renders one LED per score tick (score 10 -> 1 LED,
  score 0 -> 11 LEDs); old 9-LED cap removed.
- In IAQS mode, WiFiLost/ServerLost/SensorConfigFailed clear the bar
  and light only LED 0 with the status color, so the notification
  stays visible at any score. PM/CO2 modes keep their overlay path.
2026-05-20 23:13:14 +05:00
samuelbles07 0b7c8c0cb7 feat(led): add IAQS score LED bar mode
New 'iaqs' value for ledBarMode renders the GO IAQS Starter Score
(PM2.5 + CO2) on the ONE_INDOOR LED bar. Color from category
(Good/Moderate/Unhealthy), LEDs lit from severity; 9-LED cap
mirrors existing PM/CO2 modes so LEDs 0-1 stay free for
connectivity overlays.
2026-05-18 18:46:09 +05:00
16 changed files with 655 additions and 172 deletions
+1 -1
View File
@@ -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}` |
+141 -98
View File
@@ -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).
}
}
+34 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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 {
+142
View File
@@ -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';
}
}
+94
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -107,6 +107,7 @@ private:
bool _connected = false;
int _consecutiveErrors = 0;
BoardType _boardDef;
const BoardDef *_bsp = nullptr;
SensirionUartSps30 _driver;
HardwareSerial *_serial = nullptr;