Compare commits

...

6 Commits

Author SHA1 Message Date
samuelbles07
805b42dbb1 Add comment todo 2025-12-24 00:59:02 +07:00
samuelbles07
25669d4846 Fix satellites config not saved 2025-12-24 00:57:47 +07:00
samuelbles07
dbc0b2070f Run satellites if enabled 2025-12-24 00:55:13 +07:00
samuelbles07
2e813f72b9 Parse advertising data 2025-12-24 00:29:56 +07:00
samuelbles07
86e2ddcb61 Untested: AgSatellites
To handle BLE satellites devices
2025-12-24 00:04:59 +07:00
samuelbles07
d87a354b1c New config: satellites 2025-12-23 21:59:23 +07:00
7 changed files with 476 additions and 0 deletions

View File

@@ -27,6 +27,7 @@ CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License
*/
#include "AgConfigure.h"
#include "AgSatellites.h"
#include "AgSchedule.h"
#include "AgStateMachine.h"
#include "AgValue.h"
@@ -99,6 +100,7 @@ static TaskHandle_t mqttTask = NULL;
static Configuration configuration(Serial);
static Measurements measurements(configuration);
static AirGradient *ag;
static AgSatellites *satellites = nullptr;
static OledDisplay oledDisplay(configuration, measurements, Serial);
static StateMachine stateMachine(oledDisplay, Serial, measurements, configuration);
static WifiConnector wifiConnector(oledDisplay, Serial, stateMachine, configuration);
@@ -210,6 +212,12 @@ void setup() {
localServer.setAirGraident(ag);
measurements.setAirGradient(ag);
if (configuration.isSatellitesEnabled()) {
satellites = new AgSatellites(measurements, configuration);
measurements.setSatellites(satellites);
Serial.println("Satellites enabled on boot");
}
/** Init sensor */
boardInit();
setMeasurementMaxPeriod();
@@ -348,6 +356,11 @@ void loop() {
}
}
/* Run satellite BLE scanning */
if (satellites != nullptr) {
satellites->run();
}
/* Run measurement schedule */
printMeasurementsSchedule.run();
@@ -1142,6 +1155,15 @@ static void configUpdateHandle() {
}
}
if (configuration.isSatellitesChanged() && configuration.isSatellitesEnabled()) {
if (satellites == nullptr) {
// Initialized if satellites enabled on run time
satellites = new AgSatellites(measurements, configuration);
measurements.setSatellites(satellites);
Serial.println("Satellites enabled on runtime");
}
}
// Update display and led bar notification based on updated configuration
updateDisplayAndLedBar();
}

View File

@@ -61,6 +61,7 @@ JSON_PROP_DEF(corrections);
JSON_PROP_DEF(atmp);
JSON_PROP_DEF(rhum);
JSON_PROP_DEF(extendedPmMeasures);
JSON_PROP_DEF(satellites);
#define jprop_model_default ""
#define jprop_country_default "TH"
@@ -317,6 +318,95 @@ bool Configuration::updateTempHumCorrection(JSONVar &json, TempHumCorrection &ta
return true;
}
bool Configuration::updateSatellites(JSONVar &json) {
if (!json.hasOwnProperty(jprop_satellites)) {
emptySatellites();
return false;
}
if (JSON.typeof_(json[jprop_satellites]) != "array") {
emptySatellites();
return false;
}
JSONVar satellites = json[jprop_satellites];
if (satellites.length() == 0) {
if (_satellitesEnabled) {
// No satellites available and previously its enabled
emptySatellites();
return true; // then config is changed
}
return false;
}
bool changed = false;
// Check JSON → stored (new satellites)
for (int i = 0; i < satellites.length(); i++) {
bool found = false;
for (int j = 0; j < MAX_SATELLITES; j++) {
if (_satellites[j] == (const char *)satellites[i]) {
found = true;
break;
}
}
if (!found) {
changed = true;
break;
}
}
// Check stored → JSON (removed satellites) only if no new satellites
if (!changed) {
for (int j = 0; j < MAX_SATELLITES; j++) {
if (_satellites[j].length() == 0) {
continue;
}
bool found = false;
for (int i = 0; i < satellites.length(); i++) {
if (_satellites[j] == (const char *)satellites[i]) {
found = true;
break;
}
}
if (!found) {
changed = true;
break;
}
}
}
// Sync stored list with JSON
if (changed) {
emptySatellites();
int count = min((int)satellites.length(), MAX_SATELLITES);
for (int i = 0; i < count; i++) {
_satellites[i] = (const char *)satellites[i];
}
_satellitesChanged = true;
// Deep copy satellites from root to jconfig, so it will be saved later
jconfig[jprop_satellites] = satellites;
}
// Ensure flag is set
_satellitesEnabled = true;
return changed;
}
void Configuration::emptySatellites() {
for (int j = 0; j < MAX_SATELLITES; j++) {
_satellites[j] = "";
}
_satellitesEnabled = false;
}
/**
* @brief Save configure to device storage (EEPROM)
*
@@ -852,6 +942,11 @@ bool Configuration::parse(String data, bool isLocal) {
return false;
}
}
// Check for satellites
if (updateSatellites(root)) {
changed = true;
}
}
ledBarBrightnessChanged = false;
@@ -1656,3 +1751,13 @@ Configuration::PMCorrection Configuration::getPMCorrection(void) { return pmCorr
Configuration::TempHumCorrection Configuration::getTempCorrection(void) { return tempCorrection; }
Configuration::TempHumCorrection Configuration::getHumCorrection(void) { return rhumCorrection; }
bool Configuration::isSatellitesChanged(void) {
bool changed = _satellitesChanged;
_satellitesChanged = false;
return changed;
}
bool Configuration::isSatellitesEnabled(void) { return _satellitesEnabled; }
const String *Configuration::getSatellites() const { return _satellites; }

View File

@@ -7,6 +7,8 @@
#include <Arduino.h>
#include "Libraries/Arduino_JSON/src/Arduino_JSON.h"
#define MAX_SATELLITES 10
class Configuration : public PrintLog {
public:
struct PMCorrection {
@@ -40,6 +42,9 @@ private:
PMCorrection pmCorrection;
TempHumCorrection tempCorrection;
TempHumCorrection rhumCorrection;
bool _satellitesEnabled = false;
String _satellites[MAX_SATELLITES];
bool _satellitesChanged = false;
AirGradient* ag;
@@ -49,6 +54,8 @@ private:
bool updatePmCorrection(JSONVar &json);
bool updateTempHumCorrection(JSONVar &json, TempHumCorrection &target,
const char *correctionName);
bool updateSatellites(JSONVar &json);
void emptySatellites();
void saveConfig(void);
void loadConfig(void);
void defaultConfig(void);
@@ -122,6 +129,10 @@ public:
PMCorrection getPMCorrection(void);
TempHumCorrection getTempCorrection(void);
TempHumCorrection getHumCorrection(void);
bool isSatellitesChanged(void);
bool isSatellitesEnabled(void);
const String* getSatellites() const;
private:
ConfigurationUpdatedCallback_t _callback;
};

246
src/AgSatellites.cpp Normal file
View File

@@ -0,0 +1,246 @@
#include "AgSatellites.h"
AgSatellites::AgSatellites(Measurements &measurement, Configuration &config)
: _measurements(measurement), _config(config), _pScan(nullptr), _initialized(false),
_scanCallbacks(nullptr) {
// Initialize satellite array
for (int i = 0; i < MAX_SATELLITES; i++) {
_satellites[i].id = "";
_satellites[i].data.temp = -1000.0f;
_satellites[i].data.rhum = -1.0f;
_satellites[i].data.useCount = 0;
}
}
AgSatellites::~AgSatellites() {
// Stop scan if running
if (_pScan && _initialized) {
_pScan->stop();
}
// Cleanup callbacks
if (_scanCallbacks) {
delete _scanCallbacks;
_scanCallbacks = nullptr;
}
}
bool AgSatellites::run() {
// Check if satellites are enabled in config
if (!_config.isSatellitesEnabled()) {
return false;
}
// Lazy initialization on first run
if (!_initialized) {
// Initialize NimBLE
NimBLEDevice::init("AgSatelliteScanner");
// Get scan instance
_pScan = NimBLEDevice::getScan();
if (!_pScan) {
return false;
}
// Create and set scan callbacks
_scanCallbacks = new ScanCallbacks(this);
_pScan->setScanCallbacks(_scanCallbacks, true);
// Configure scan parameters
// TODO: Might need to adjust for reliablity coex wifi
_pScan->setInterval(100); // Scan interval in ms
_pScan->setWindow(99); // Scan window in ms
_pScan->setActiveScan(true); // Active scan for scan response data
// Start continuous scanning (0 = scan forever)
_pScan->start(0, false);
_initialized = true;
}
// Check if scan is still running, restart if needed
if (_pScan && !_pScan->isScanning()) {
_pScan->start(0, false);
}
return true;
}
bool AgSatellites::isSatelliteInList(String macAddress) {
const String *satellites = _config.getSatellites();
if (!satellites) {
return false;
}
// Check if MAC address is in the configured satellite list
for (int i = 0; i < MAX_SATELLITES; i++) {
if (satellites[i].length() > 0 && satellites[i].equalsIgnoreCase(macAddress)) {
return true;
}
}
return false;
}
void AgSatellites::processAdvertisedDevice(const NimBLEAdvertisedDevice *device) {
if (!device) {
return;
}
// Get MAC address
String macAddress = device->getAddress().toString().c_str();
// Check if this device is in our satellite list
if (!isSatelliteInList(macAddress)) {
return;
}
Serial.printf("Found satellites: %s\n", macAddress.c_str());
// Get advertising payload
const std::vector<uint8_t> &payload = device->getPayload();
if (payload.empty()) {
return;
}
// Find or create entry in satellites array
int index = -1;
bool isNewEntry = false;
for (int i = 0; i < MAX_SATELLITES; i++) {
if (_satellites[i].id == macAddress) {
index = i;
break;
} else if (_satellites[i].id.length() == 0 && index == -1) {
// Found empty slot
index = i;
isNewEntry = true;
}
}
if (index >= 0) {
// TODO: What happen if old satellites is removed?
// Only set ID if it's a new entry
if (isNewEntry) {
_satellites[index].id = macAddress;
}
// Parse BTHome advertising data
SatelliteData newData;
if (decodeBTHome(payload.data(), payload.size(), newData)) {
// Successfully parsed - reset use count when new data is received
_satellites[index].data.temp = newData.temp;
_satellites[index].data.rhum = newData.rhum;
_satellites[index].data.useCount = 0;
}
}
}
bool AgSatellites::decodeBTHome(const uint8_t *payload, size_t size, SatelliteData &data) {
// Initialize with invalid values
data.temp = -1000.0f;
data.rhum = -1.0f;
// Walk through BLE AD structures: [len][type][data...]
for (size_t i = 0; i + 1 < size;) {
uint8_t len = payload[i];
if (len == 0) {
break; // No more AD structures
}
// Check if we have enough data
if (i + 1 + len > size) {
break; // Malformed
}
uint8_t type = payload[i + 1];
// Service Data - 16-bit UUID (type 0x16)
if (type == 0x16 && len >= 3) {
// UUID is at i+2 (little-endian)
uint16_t uuid = payload[i + 2] | (payload[i + 3] << 8);
// Check for BTHome UUID (0xFCD2)
if (uuid == 0xFCD2) {
// BTHome payload begins after UUID
size_t p = i + 4;
size_t end = i + 1 + len; // End of this AD structure
if (p >= end) {
return false;
}
uint8_t device_info = payload[p++];
// Check if encrypted
bool encrypted = (device_info & 0x01);
if (encrypted) {
// Not handling encryption
return false;
}
bool found_data = false;
// Parse BTHome v2 objects
while (p < end) {
uint8_t obj_id = payload[p++];
switch (obj_id) {
case 0x02: // Temperature (sint16, ×0.01 °C)
if (p + 2 > end)
return false;
{
int16_t raw = (int16_t)(payload[p] | (payload[p + 1] << 8));
data.temp = raw * 0.01f;
p += 2;
found_data = true;
}
break;
case 0x03: // Humidity (uint16, ×0.01 %)
if (p + 2 > end)
return false;
{
uint16_t raw = (uint16_t)(payload[p] | (payload[p + 1] << 8));
data.rhum = raw * 0.01f;
p += 2;
found_data = true;
}
break;
case 0x00: // Packet ID (uint8) - skip
if (p + 1 > end)
return false;
p += 1;
break;
case 0x01: // Battery (uint8) - skip
if (p + 1 > end)
return false;
p += 1;
break;
case 0x0C: // Voltage (uint16) - skip
if (p + 2 > end)
return false;
p += 2;
break;
default:
// Unknown object ID - stop parsing but return what we have
return found_data;
}
}
return found_data;
}
}
// Go to next AD structure
i += 1 + len;
}
return false; // No BTHome data found
}
AgSatellites::Satellite *AgSatellites::getSatellites() { return _satellites; }

59
src/AgSatellites.h Normal file
View File

@@ -0,0 +1,59 @@
#ifndef _AG_SATELLITES_H_
#define _AG_SATELLITES_H_
#include <Arduino.h>
#include <NimBLEDevice.h>
#include "AgConfigure.h"
#include "AgValue.h"
class AgSatellites {
public:
struct SatelliteData {
float temp;
float rhum;
uint8_t useCount;
};
struct Satellite {
String id;
SatelliteData data;
};
private:
Satellite _satellites[MAX_SATELLITES];
Measurements &_measurements;
Configuration &_config;
NimBLEScan *_pScan;
bool _initialized;
// NimBLE scan callback class
class ScanCallbacks : public NimBLEScanCallbacks {
private:
AgSatellites *_parent;
public:
ScanCallbacks(AgSatellites *parent) : _parent(parent) {}
void onResult(const NimBLEAdvertisedDevice *advertisedDevice) override {
if (_parent) {
_parent->processAdvertisedDevice(advertisedDevice);
}
}
};
ScanCallbacks *_scanCallbacks;
// Helper methods
bool isSatelliteInList(String macAddress);
void processAdvertisedDevice(const NimBLEAdvertisedDevice *device);
bool decodeBTHome(const uint8_t* payload, size_t size, SatelliteData &data);
public:
AgSatellites(Measurements &measurement, Configuration &config);
~AgSatellites();
bool run();
Satellite* getSatellites();
};
#endif // _AG_SATELLITES_H_

View File

@@ -1,5 +1,6 @@
#include "AgValue.h"
#include "AgConfigure.h"
#include "AgSatellites.h"
#include "AirGradient.h"
#include "App/AppDef.h"
#include <cmath>
@@ -76,6 +77,8 @@ Measurements::Measurements(Configuration &config) : config(config) {
void Measurements::setAirGradient(AirGradient *ag) { this->ag = ag; }
void Measurements::setSatellites(AgSatellites* satellites) { this->satellites_ = satellites; }
void Measurements::printCurrentAverage() {
Serial.println();
if (config.hasSensorS8) {
@@ -1106,6 +1109,31 @@ String Measurements::toString(bool localServer, AgFirmwareMode fwMode, int rssi)
#endif
}
// Add satellites data
if (satellites_ && config.isSatellitesEnabled()) {
AgSatellites::Satellite* satellites = satellites_->getSatellites();
JSONVar satellitesObj;
int count = 0;
for (int i = 0; i < MAX_SATELLITES; i++) {
if (satellites[i].id.length() > 0 &&
satellites[i].data.useCount < 2 &&
utils::isValidTemperature(satellites[i].data.temp) &&
utils::isValidHumidity(satellites[i].data.rhum)) {
String macKey = satellites[i].id;
satellitesObj[macKey.c_str()]["atmp"] = ag->round2(satellites[i].data.temp);
satellitesObj[macKey.c_str()]["rhum"] = ag->round2(satellites[i].data.rhum);
satellites[i].data.useCount++;
count++;
}
}
if (count > 0) {
root["satellites"] = satellitesObj;
}
}
String result = JSON.stringify(root);
Serial.printf("\n---- PAYLOAD\n %s \n-----\n", result.c_str());
return result;

View File

@@ -11,6 +11,9 @@
#include <vector>
#include <string>
// Forward declaration
class AgSatellites;
class Measurements {
private:
// Generic struct for update indication for respective value
@@ -65,6 +68,7 @@ public:
};
void setAirGradient(AirGradient *ag);
void setSatellites(AgSatellites* satellites);
// Enumeration for every AG measurements
enum MeasurementType {
@@ -202,6 +206,7 @@ public:
private:
Configuration &config;
AirGradient *ag;
AgSatellites* satellites_ = nullptr;
// Some declared as an array (channel), because FW_MODE_O_1PPx has two PMS5003T
FloatValue _temperature[2];