28 lastPublishedStatus =
"";
29 pendingStatusPublish =
false;
30 pendingConfigApply =
false;
37 Log.info(
"Setting up Cloud configuration management");
40 defaultSettingsLedger = Particle.ledger(
"default-settings");
41 defaultSettingsLedger.onSync(onDefaultSettingsSync);
44 deviceSettingsLedger = Particle.ledger(
"device-settings");
45 deviceSettingsLedger.onSync(onDeviceSettingsSync);
47 deviceStatusLedger = Particle.ledger(
"device-status");
48 deviceDataLedger = Particle.ledger(
"device-data");
50 Log.info(
"Ledgers configured:");
51 Log.info(
" default-settings: Product defaults (Cloud→Device)");
52 Log.info(
" device-settings: Device overrides (Cloud→Device)");
53 Log.info(
" device-status: Current config (Device→Cloud)");
54 Log.info(
" device-data: Sensor readings (Device→Cloud)");
58void Cloud::onDefaultSettingsSync(Ledger ledger) {
59 Log.info(
"default-settings synced from cloud");
66void Cloud::onDeviceSettingsSync(Ledger ledger) {
67 Log.info(
"device-settings synced from cloud");
74void Cloud::mergeConfiguration() {
76 LedgerData defaults = defaultSettingsLedger.get();
77 LedgerData device = deviceSettingsLedger.get();
80 mergedConfig = defaults;
94 bool haveDefaultSensor = defaults.has(
"sensor") && defaults.get(
"sensor").isMap();
95 bool haveDeviceSensor = device.has(
"sensor") && device.get(
"sensor").isMap();
97 if (haveDefaultSensor) {
98 Variant defaultSensor = defaults.get(
"sensor");
99 if (defaultSensor.has(
"threshold1")) {
100 threshold1 = defaultSensor.get(
"threshold1").toInt();
102 if (defaultSensor.has(
"threshold2")) {
103 threshold2 = defaultSensor.get(
"threshold2").toInt();
109 if (defaults.has(
"sensorThreshold")) {
110 int base = defaults.get(
"sensorThreshold").toInt();
115 if (haveDeviceSensor) {
116 Variant deviceSensor = device.get(
"sensor");
117 if (deviceSensor.has(
"threshold1")) {
118 int override1 = deviceSensor.get(
"threshold1").toInt();
119 threshold1 = override1;
121 if (deviceSensor.has(
"threshold2")) {
122 int override2 = deviceSensor.get(
"threshold2").toInt();
123 threshold2 = override2;
126 if (device.has(
"sensorThreshold")) {
127 int override = device.get(
"sensorThreshold").toInt();
128 threshold1 =
override;
129 threshold2 =
override;
133 VariantMap mergedSensor;
134 mergedSensor[
"threshold1"] = Variant(threshold1);
135 mergedSensor[
"threshold2"] = Variant(threshold2);
137 mergedConfig.set(
"sensor", Variant(mergedSensor));
141 if (device.has(
"timing")) mergedConfig.set(
"timing", device.get(
"timing"));
142 if (device.has(
"power")) mergedConfig.set(
"power", device.get(
"power"));
143 if (device.has(
"messaging")) mergedConfig.set(
"messaging", device.get(
"messaging"));
144 if (device.has(
"modes")) mergedConfig.set(
"modes", device.get(
"modes"));
146 lastApplySuccess = applyConfigurationFromLedger();
148 if (!lastApplySuccess) {
149 Log.warn(
"Configuration apply failed");
154 Log.info(
"Syncing configuration from cloud");
158 mergeConfiguration();
159 return lastApplySuccess;
162bool Cloud::applyConfigurationFromLedger() {
165 success &= applySensorConfig();
166 success &= applyTimingConfig();
167 success &= applyPowerConfig();
168 success &= applyMessagingConfig();
169 success &= applyModesConfig();
180 pendingStatusPublish =
true;
182 Log.warn(
"Some configuration sections failed to apply");
191 if (pendingConfigApply && Particle.connected()) {
192 pendingConfigApply =
false;
193 mergeConfiguration();
199 if (pendingStatusPublish && Particle.connected()) {
201 pendingStatusPublish =
false;
206bool Cloud::applyMessagingConfig() {
207 if (!mergedConfig.has(
"messaging"))
return true;
208 Variant messaging = mergedConfig.get(
"messaging");
210 if (!messaging.isMap())
return true;
213 bool changed =
false;
215 if (messaging.has(
"serial")) {
216 bool serialEnabled = messaging.get(
"serial").toBool();
217 if (
sysStatus.get_serialConnected() != serialEnabled) {
218 sysStatus.set_serialConnected(serialEnabled);
219 Log.info(
"Config: Serial → %s", serialEnabled ?
"ON" :
"OFF");
224 if (messaging.has(
"verboseMode")) {
225 bool verboseMode = messaging.get(
"verboseMode").toBool();
226 if (
sysStatus.get_verboseMode() != verboseMode) {
228 Log.info(
"Config: Verbose → %s", verboseMode ?
"ON" :
"OFF");
233 if (changed) Log.info(
"Messaging config updated");
237bool Cloud::applyTimingConfig() {
238 if (!mergedConfig.has(
"timing"))
return true;
239 Variant timing = mergedConfig.get(
"timing");
241 if (!timing.isMap())
return true;
244 bool changed =
false;
246 if (timing.has(
"timezone")) {
247 String timezone = timing.get(
"timezone").toString();
248 if (timezone.length() > 0 && timezone.length() < 39) {
249 if (strcmp(
sysStatus.get_timeZoneStr(), timezone.c_str()) != 0) {
250 sysStatus.set_timeZoneStr(timezone.c_str());
251 Log.info(
"Config: Timezone → %s", timezone.c_str());
255 Log.warn(
"Invalid timezone length: %d", timezone.length());
260 if (timing.has(
"reportingIntervalSec")) {
261 int reportingInterval = timing.get(
"reportingIntervalSec").toInt();
262 if (validateRange(reportingInterval, 300, 86400,
"timing.reportingIntervalSec")) {
263 if (
sysStatus.get_reportingInterval() != reportingInterval) {
264 sysStatus.set_reportingInterval(reportingInterval);
265 Log.info(
"Config: Reporting interval → %ds", reportingInterval);
273 if (timing.has(
"pollingRateSec")) {
274 int pollingRate = timing.get(
"pollingRateSec").toInt();
275 if (validateRange(pollingRate, 0, 3600,
"timing.pollingRateSec")) {
278 Log.info(
"Config: Polling rate → %ds", pollingRate);
286 if (timing.has(
"openHour")) {
287 int openHour = timing.get(
"openHour").toInt();
288 if (validateRange(openHour, 0, 23,
"timing.openHour")) {
289 if (
sysStatus.get_openTime() != openHour) {
291 Log.info(
"Config: Open hour → %d", openHour);
299 if (timing.has(
"closeHour")) {
300 int closeHour = timing.get(
"closeHour").toInt();
301 if (validateRange(closeHour, 0, 23,
"timing.closeHour")) {
302 if (
sysStatus.get_closeTime() != closeHour) {
304 Log.info(
"Config: Close hour → %d", closeHour);
312 if (changed) Log.info(
"Timing config updated");
316bool Cloud::applyPowerConfig() {
317 if (!mergedConfig.has(
"power"))
return true;
318 Variant power = mergedConfig.get(
"power");
320 if (!power.isMap())
return true;
323 bool changed =
false;
325 if (power.has(
"solarPowerMode")) {
326 bool solarPowerMode = power.get(
"solarPowerMode").toBool();
327 if (
sysStatus.get_solarPowerMode() != solarPowerMode) {
328 sysStatus.set_solarPowerMode(solarPowerMode);
329 Log.info(
"Config: Solar power → %s", solarPowerMode ?
"ON" :
"OFF");
334 if (changed) Log.info(
"Power config updated");
340bool Cloud::validateRange(T value, T min, T max,
const String& name) {
341 if (value < min || value > max) {
342 Log.warn(
"Invalid %s value: %d (must be between %d and %d)", name.c_str(), (
int)value, (
int)min, (
int)max);
348bool Cloud::applySensorConfig() {
349 if (!mergedConfig.has(
"sensor"))
return true;
351 Variant sensor = mergedConfig.get(
"sensor");
352 if (!sensor.isMap())
return true;
355 bool changed =
false;
358 if (sensor.has(
"threshold1")) {
359 int threshold1 = sensor.get(
"threshold1").toInt();
360 if (validateRange(threshold1, 0, 100,
"sensor.threshold1")) {
363 Log.info(
"Config: Threshold1 → %d", threshold1);
372 if (sensor.has(
"threshold2")) {
373 int threshold2 = sensor.get(
"threshold2").toInt();
374 if (validateRange(threshold2, 0, 100,
"sensor.threshold2")) {
377 Log.info(
"Config: Threshold2 → %d", threshold2);
385 if (changed) Log.info(
"Sensor config updated");
389bool Cloud::applyModesConfig() {
390 if (!mergedConfig.has(
"modes"))
return true;
391 Variant modes = mergedConfig.get(
"modes");
393 if (!modes.isMap()) {
398 bool changed =
false;
401 if (modes.has(
"countingMode")) {
402 int countingMode = modes.get(
"countingMode").asInt();
403 if (validateRange(countingMode, 0, 2,
"countingMode")) {
406 const char *modeStr = countingMode ==
COUNTING ?
"COUNTING" :
407 countingMode ==
OCCUPANCY ?
"OCCUPANCY" :
"SCHEDULED";
408 Log.info(
"Config: Counting mode → %s", modeStr);
417 if (modes.has(
"operatingMode")) {
418 int operatingMode = modes.get(
"operatingMode").asInt();
419 if (validateRange(operatingMode, 0, 2,
"operatingMode")) {
422 const char *modeStr = operatingMode == 0 ?
"CONNECTED" :
423 operatingMode == 1 ?
"LOW_POWER" :
"DISCONNECTED";
424 Log.info(
"Config: Operating mode → %s", modeStr);
433 if (modes.has(
"occupancyDebounceMs")) {
434 unsigned long occupancyDebounceMs = modes.get(
"occupancyDebounceMs").asUInt();
435 if (validateRange(occupancyDebounceMs, 0UL, 600000UL,
"occupancyDebounceMs")) {
436 if (
sysStatus.get_occupancyDebounceMs() != occupancyDebounceMs) {
437 sysStatus.set_occupancyDebounceMs(occupancyDebounceMs);
438 Log.info(
"Config: Occupancy debounce → %lu ms", occupancyDebounceMs);
447 if (modes.has(
"connectedReportingIntervalSec")) {
448 int connectedReportingIntervalSec = modes.get(
"connectedReportingIntervalSec").asInt();
449 if (validateRange(connectedReportingIntervalSec, 60, 86400,
"connectedReportingIntervalSec")) {
450 if (
sysStatus.get_connectedReportingIntervalSec() != connectedReportingIntervalSec) {
451 sysStatus.set_connectedReportingIntervalSec(connectedReportingIntervalSec);
452 Log.info(
"Config: Connected reporting interval → %ds", connectedReportingIntervalSec);
461 if (modes.has(
"lowPowerReportingIntervalSec")) {
462 int lowPowerReportingIntervalSec = modes.get(
"lowPowerReportingIntervalSec").asInt();
463 if (validateRange(lowPowerReportingIntervalSec, 300, 86400,
"lowPowerReportingIntervalSec")) {
464 if (
sysStatus.get_lowPowerReportingIntervalSec() != lowPowerReportingIntervalSec) {
465 sysStatus.set_lowPowerReportingIntervalSec(lowPowerReportingIntervalSec);
466 Log.info(
"Config: Low power reporting interval → %ds", lowPowerReportingIntervalSec);
475 if (modes.has(
"connectAttemptBudgetSec")) {
476 int connectAttemptBudgetSec = modes.get(
"connectAttemptBudgetSec").asInt();
477 if (validateRange(connectAttemptBudgetSec, 30, 900,
"connectAttemptBudgetSec")) {
478 if (
sysStatus.get_connectAttemptBudgetSec() != connectAttemptBudgetSec) {
479 sysStatus.set_connectAttemptBudgetSec((uint16_t)connectAttemptBudgetSec);
480 Log.info(
"Config: Connect budget → %ds", connectAttemptBudgetSec);
489 if (modes.has(
"cloudDisconnectBudgetSec")) {
490 int cloudDisconnectBudgetSec = modes.get(
"cloudDisconnectBudgetSec").asInt();
491 if (validateRange(cloudDisconnectBudgetSec, 5, 120,
"cloudDisconnectBudgetSec")) {
492 if (
sysStatus.get_cloudDisconnectBudgetSec() != cloudDisconnectBudgetSec) {
493 sysStatus.set_cloudDisconnectBudgetSec((uint16_t)cloudDisconnectBudgetSec);
494 Log.info(
"Config: Disconnect budget → %ds", cloudDisconnectBudgetSec);
503 if (modes.has(
"modemOffBudgetSec")) {
504 int modemOffBudgetSec = modes.get(
"modemOffBudgetSec").asInt();
505 if (validateRange(modemOffBudgetSec, 5, 120,
"modemOffBudgetSec")) {
506 if (
sysStatus.get_modemOffBudgetSec() != modemOffBudgetSec) {
507 sysStatus.set_modemOffBudgetSec((uint16_t)modemOffBudgetSec);
508 Log.info(
"Config: Modem off budget → %ds", modemOffBudgetSec);
516 if (changed) Log.info(
"Modes config updated");
523 JSONBufferWriter writer(buffer,
sizeof(buffer));
525 writer.beginObject();
531 writer.name(
"sensor").beginObject();
532 writer.name(
"threshold1").value(
sensorConfig.get_threshold1());
533 writer.name(
"threshold2").value(
sensorConfig.get_threshold2());
537 writer.name(
"timing").beginObject();
538 writer.name(
"timezone").value(
sysStatus.get_timeZoneStr());
539 writer.name(
"reportingIntervalSec").value(
sysStatus.get_reportingInterval());
540 writer.name(
"openHour").value(
sysStatus.get_openTime());
541 writer.name(
"closeHour").value(
sysStatus.get_closeTime());
545 writer.name(
"power").beginObject();
546 writer.name(
"lowPowerMode").value(
sysStatus.get_lowPowerMode());
547 writer.name(
"solarPowerMode").value(
sysStatus.get_solarPowerMode());
551 writer.name(
"modes").beginObject();
552 writer.name(
"countingMode").value((
int)
sysStatus.get_countingMode());
553 writer.name(
"operatingMode").value((
int)
sysStatus.get_operatingMode());
554 writer.name(
"occupancyDebounceMs").value((
int)
sysStatus.get_occupancyDebounceMs());
555 writer.name(
"connectedReportingIntervalSec").value((
int)
sysStatus.get_connectedReportingIntervalSec());
556 writer.name(
"lowPowerReportingIntervalSec").value((
int)
sysStatus.get_lowPowerReportingIntervalSec());
557 writer.name(
"connectAttemptBudgetSec").value((
int)
sysStatus.get_connectAttemptBudgetSec());
562 if (!writer.buffer()) {
563 Log.warn(
"Failed to create status JSON");
567 buffer[writer.dataSize()] =
'\0';
570 String currentStatus = String(buffer);
571 if (lastPublishedStatus == currentStatus) {
572 Log.info(
"Device status unchanged; skipping device-status ledger update");
576 LedgerData data = LedgerData::fromJSON(buffer);
577 int result = deviceStatusLedger.set(data);
579 if (result == SYSTEM_ERROR_NONE) {
580 lastPublishedStatus = currentStatus;
581 Log.info(
"Device status published to cloud");
584 Log.warn(
"Failed to publish device status: %d", result);
590 Log.info(
"Publishing sensor data to device-data ledger");
593 JSONBufferWriter writer(buffer,
sizeof(buffer));
595 writer.beginObject();
596 writer.name(
"timestamp").value((
int)Time.now());
600 writer.name(
"resetReason").value((
int)System.resetReason());
601 writer.name(
"resetReasonData").value((
unsigned long)System.resetReasonData());
603 uint8_t countingMode =
sysStatus.get_countingMode();
606 writer.name(
"mode").value(
"counting");
607 writer.name(
"hourlyCount").value(
current.get_hourlyCount());
608 writer.name(
"dailyCount").value(
current.get_dailyCount());
610 writer.name(
"mode").value(
"occupancy");
611 writer.name(
"occupied").value(
current.get_occupied());
612 writer.name(
"totalOccupiedSec").value(
current.get_totalOccupiedSeconds());
614 writer.name(
"mode").value(
"scheduled");
617 writer.name(
"hourlyCount").value(
current.get_hourlyCount());
618 writer.name(
"dailyCount").value(
current.get_dailyCount());
621 writer.name(
"battery").value(
current.get_stateOfCharge(), 1);
622 writer.name(
"temp").value(
current.get_internalTempC(), 1);
625 if (!writer.buffer()) {
626 Log.warn(
"Failed to create data JSON");
630 buffer[writer.dataSize()] =
'\0';
632 LedgerData data = LedgerData::fromJSON(buffer);
633 int result = deviceDataLedger.set(data);
635 if (result == SYSTEM_ERROR_NONE) {
640 Log.info(
"Sensor data published to cloud - mode=%s hourly=%d daily=%d alert=%d",
641 (mode ==
COUNTING ?
"counting" :
"scheduled"),
642 (
int)
current.get_hourlyCount(),
646 Log.info(
"Sensor data published to cloud - mode=occupancy occupied=%d totalSec=%lu alert=%d",
648 (
unsigned long)
current.get_totalOccupiedSeconds(),
651 Log.info(
"Sensor data published to cloud - mode=unknown alert=%d", (
int)
current.get_alertCode());
655 Log.warn(
"Failed to publish sensor data: %d", result);
660bool Cloud::hasNonDefaultConfig() {
670template bool Cloud::validateRange<int>(
int value,
int min,
int max,
const String& name);
671template bool Cloud::validateRange<unsigned long>(
unsigned long value,
unsigned long min,
unsigned long max,
const String& name);
const char * FIRMWARE_VERSION
Cloud Configuration Management - Particle Ledger integration for device configuration.
CountingMode
Counting mode defines how the device processes sensor events.
OperatingMode
Operating mode defines power and connectivity behavior.
This class is a singleton; you do not create one as a global, on the stack, or with new.
bool writeDeviceStatusToCloud()
Write current device configuration to device-status ledger.
void setup()
Perform setup operations; call this from global application setup().
bool loadConfigurationFromCloud()
Load and apply configuration from cloud ledgers.
bool publishDataToLedger()
Publish latest sensor data to device-data ledger.
static Cloud * _instance
Singleton instance of this class.
static Cloud & instance()
Gets the singleton instance of this class, allocating it if necessary.
void loop()
Service deferred cloud work; call from main loop.
virtual ~Cloud()
The destructor is protected because the class is a singleton and cannot be deleted.
Cloud()
The constructor is protected because the class is a singleton.