-
Notifications
You must be signed in to change notification settings - Fork 0
/
waterbot.cpp
630 lines (519 loc) · 21.4 KB
/
waterbot.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
// ------------
// Pulse counter
// ------------
#include <Particle.h>
#include <CircularBuffer.h>
#include <PowerShield.h>
STARTUP(System.enableFeature(FEATURE_RETAINED_MEMORY));
STARTUP(WiFi.selectAntenna(ANT_AUTO));
SYSTEM_MODE(SEMI_AUTOMATIC); // wait to connect until we want to
SYSTEM_THREAD(ENABLED);
const char * const WATERBOT_VERSION = "0.3.9";
// Behavior constants
// publish no more often than the in-use interval while water is running;
// but when water isn't running, publish at least once every heartbeat interval
const std::chrono::seconds PUBLISH_IN_USE_INTERVAL = 1min;
const std::chrono::seconds PUBLISH_HEARTBEAT_INTERVAL = 4h;
// try to publish immediately if this many pulses
// accumulate before PUBLISH_IN_USE_INTERVAL is reached
const uint32_t PUBLISH_MAX_PULSE_TIMES = 20;
// how many detailed pulse times we can store
// (without publishing, while cloud connection is unavailable);
// beyond this, the total reading will still be accurate,
// but older individual pulse times will be lost
const uint32_t PULSE_TIMES_BUFFER_SIZE = 700;
// pressing the reset button will wake up, connect to the cloud,
// and stay away this long (for setup/diagnostics/updates):
const std::chrono::seconds RESET_STAY_AWAKE_INTERVAL = 10min;
// don't bother sleeping for less than this
const std::chrono::seconds MIN_SLEEP_INTERVAL = 10s;
// never publish more often than this (Particle event throttling)
const std::chrono::seconds PUBLISH_MIN_INTERVAL = 5s;
// timeouts for connecting to WiFi and cloud
const std::chrono::seconds NETWORK_CONNECT_TIMEOUT = 15s;
const std::chrono::seconds CLOUD_CONNECT_TIMEOUT = 30s;
const std::chrono::seconds CLOUD_DISCONNECT_TIMEOUT = 10s;
// retry delays when experiencing network issues
const std::chrono::seconds NETWORK_PROBLEM_INITIAL_DELAY = 1min;
const std::chrono::seconds NETWORK_PROBLEM_MAX_DELAY = 1h;
// timing for pulse signalling (on the user LED)
const std::chrono::milliseconds SIGNAL_MSEC_ON = 350ms;
const std::chrono::milliseconds SIGNAL_MSEC_OFF = 150ms;
// reject pulses shorter than this as noise
// (must be less than meter pulse width at maximum flow)
const std::chrono::milliseconds DEBOUNCE_MSEC = 300ms;
// Hardware constants
// PIN_PULSE_SWITCH is meter connection:
// * must support attachInterrupt
// * must support wake from ultra-low-power sleep
// * must not conflict with PowerShield (D0, D1, or D3)
const pin_t PIN_PULSE_SWITCH = D2; // Meter
const pin_t PIN_LED_SIGNAL = D7; // Blink to indicate meter pulses
// Cloud messaging constants
const char* const EVENT_DATA = "waterbot/data"; // publish data to cloud
const char* const FUNC_SET_READING = "setReading"; // reset from cloud
const char* const FUNC_PUBLISH_NOW = "publishNow";
const char* const FUNC_SLEEP_NOW = "sleepNow";
const char* const FUNC_SELECT_ANTENNA = "selectAntenna";
// FIFO of pulse timestamps (as Time.now() values).
// Populated by pulseISR. Consumed by publishData.
// On overflow, oldest pulse timestamps are lost.
// (Note that CircularBuffer operations are not thread- or
// interrupt-safe, so should be wrapped in ATOMIC_BLOCK.)
typedef CircularBuffer<time32_t, PULSE_TIMES_BUFFER_SIZE> PulseTimesBuffer;
// Version of DeviceOS Timer that supports chrono expressions in constructor.
class MillisecondTimer : public Timer {
public:
MillisecondTimer(std::chrono::milliseconds period, timer_callback_fn callback_, bool one_shot=false)
: Timer(period.count(), callback_, one_shot) {}
};
// Value that is not (and is always less than) Time.now()
const time32_t INVALID_TIME = 0;
//
// Retained data (backup RAM / SRAM)
// So long as the device maintains battery power, this data will survive
// reset, all forms of sleep (including hibernate), and (often) firmware updates.
//
typedef struct {
// Keep all retained data in a single struct to prevent the compiler from
// rearranging it in newer versions. (It may still get relocated, which is
// detected by the magic number.)
// https://community.particle.io/t/retained-variables-are-reset-after-adding-a-new-one/58847/2
uint32_t magic;
uint16_t size;
uint16_t dataLayoutVersion;
// Current meter reading:
volatile uint32_t currentPulseCount;
// Updated on successful publish:
time32_t lastPublishTime;
uint32_t lastPublishPulseCount;
uint32_t publishCount; // number of publishes since power up
// In-progress publish attempt:
time32_t pendingPublishTime; // INVALID_TIME if publish not in progress
uint32_t pendingPublishPulseCount;
uint32_t pendingPublishFailureCount;
std::array<time32_t, PUBLISH_MAX_PULSE_TIMES + 2> pendingPublishPulseTimes;
// (+2 in case a few pulses sneak in as we're waking and deciding whether to publish)
// Captured, not-yet-reported times for each pulse:
// PulseTimesBuffer pulseTimes; // (doesn't work, because constructor runs on every reset)
uint8_t pulseTimesBuf[sizeof(PulseTimesBuffer)]; // workaround
PulseTimesBuffer& pulseTimes = reinterpret_cast<PulseTimesBuffer&>(pulseTimesBuf);
// If you add fields, add an initializer to validateRetainedData().
// If you rearrange or resize any fields, also increment this:
const uint16_t CURRENT_DATA_LAYOUT_VERSION = 4;
} retainedData_t;
retained retainedData_t retainedData;
static_assert(sizeof(retainedData_t) <= 3068,
"Photon has only 3068 bytes of backup RAM for retainedData.");
// Simplify read access to retainedData members:
const auto& currentPulseCount = retainedData.currentPulseCount;
const auto& lastPublishTime = retainedData.lastPublishTime;
const auto& lastPublishPulseCount = retainedData.lastPublishPulseCount;
const auto& publishCount = retainedData.publishCount;
const auto& pendingPublishTime = retainedData.pendingPublishTime;
const auto& pendingPublishPulseCount = retainedData.pendingPublishPulseCount;
const auto& pendingPublishFailureCount = retainedData.pendingPublishFailureCount;
const auto& pendingPublishPulseTimes = retainedData.pendingPublishPulseTimes;
const auto& pulseTimes = retainedData.pulseTimes;
// Don't change this (or you will invalidate all retainedData).
// It's just a fixed, randomly-generated, non-zero number.
const uint32_t RETAINED_DATA_MAGIC = 0x8abfc1b1;
//
// Non-persistent global data (lost during hibernate or reset)
//
volatile uint32_t pulsesToSignal = 0;
volatile bool publishImmediately = false;
time32_t stayAwakeUntilTime = 0; // prevents sleeping when > Time.now()
time32_t earliestNextPublishTime = 0; // delays publish attempts when > Time.now()
time32_t networkProblemRetryDelay = 0; // seconds; 0 when no network problems
PowerShield batteryMonitor;
Thread *pulseSignalThread = nullptr;
void pulseTimerCallback(void);
MillisecondTimer pulseDebounceTimer(DEBOUNCE_MSEC, pulseTimerCallback, true);
LEDStatus ledSignalNetworkProblem(
RGB_COLOR_ORANGE, LED_PATTERN_BLINK,
LED_SPEED_FAST, LED_PRIORITY_NORMAL);
LEDStatus ledSignalTimeInvalid(
RGB_COLOR_RED, LED_PATTERN_BLINK,
LED_SPEED_FAST, LED_PRIORITY_IMPORTANT);
//
// Code
//
bool validateRetainedData() {
// Verify retainedData is usable, or initialize if not.
// Returns false if data needed to be reinitialized.
if (retainedData.magic == RETAINED_DATA_MAGIC
&& retainedData.size == sizeof(retainedData)
&& retainedData.dataLayoutVersion == retainedData.CURRENT_DATA_LAYOUT_VERSION
) {
// retainedData is (probably) fine
return true;
}
// Either retainedData has never been initialized,
// or its layout has changed (due to a firmware update).
// For now, just re-initialize everything.
// (Could get fancier with version migrations if needed later.)
retainedData.currentPulseCount = 0;
retainedData.lastPublishTime = INVALID_TIME;
retainedData.lastPublishPulseCount = 0;
retainedData.publishCount = 0;
retainedData.pendingPublishTime = INVALID_TIME;
retainedData.pendingPublishPulseCount = 0;
retainedData.pendingPublishFailureCount = 0;
retainedData.pendingPublishPulseTimes.fill(INVALID_TIME);
retainedData.pulseTimes.clear(); // equivalent to CircularBuffer constructor
// If you add new retained data above, be sure to add
// an equivalent initializer here.
retainedData.magic = RETAINED_DATA_MAGIC;
retainedData.size = sizeof(retainedData);
retainedData.dataLayoutVersion = retainedData.CURRENT_DATA_LAYOUT_VERSION;
return false;
}
void pulseISR() {
// Interrupt handler for PIN_PULSE_SWITCH.
// Start (restart) the debounce timer.
// For a real pulse, the switch will still be closed when the timer fires.
// For a bounce, we'll end up back in here (and restart the timer) shortly.
// For transient noise, the switch will re-open before the timer fires.
pulseDebounceTimer.resetFromISR(); // also starts timer if not already running
}
void pulseTimerCallback() {
// Callback for debounceTimer.
// If pulse switch has stayed closed, record a pulse.
// (If switch opened during the timer period, ignore it as noise.)
ATOMIC_BLOCK() {
if (digitalRead(PIN_PULSE_SWITCH) == LOW) {
retainedData.currentPulseCount += 1;
pulsesToSignal += 1;
if (Time.isValid()) {
retainedData.pulseTimes.push(Time.now());
}
}
}
}
// convert a std::chrono::duration to a time32_t
// timestamp with the same units as Time.now().
inline time32_t asTime32(std::chrono::seconds duration) {
return duration.count();
}
// Return Time.now() without blocking or cloud connection.
// Enables invalid time LED signal if RTC has gone invalid.
// (Do not call from ISRs.)
time32_t nowTime(time32_t resultIfInvalid = INVALID_TIME) {
bool isValid = Time.isValid();
if (isValid != !ledSignalTimeInvalid.isActive()) {
ledSignalTimeInvalid.setActive(!isValid);
}
return isValid ? Time.now() : resultIfInvalid;
}
inline bool hasPendingPublish() {
return pendingPublishTime != INVALID_TIME;
}
time32_t calcNextPublishTime() {
// Return timestamp for next desired publish, or 0 for publish immediately.
time32_t nextPublishTime;
if (publishImmediately || hasPendingPublish()) {
nextPublishTime = 0;
} else {
// Publish when pulses to report, or at heartbeat if sooner
nextPublishTime = lastPublishTime + asTime32(PUBLISH_HEARTBEAT_INTERVAL);
ATOMIC_BLOCK() {
if (!pulseTimes.isEmpty()) {
if (pulseTimes.isFull() || pulseTimes.size() >= PUBLISH_MAX_PULSE_TIMES) {
// Too many pulseTimes; publish immediately
nextPublishTime = 0;
} else {
// Publish accumulated data after in-use interval
nextPublishTime = std::min(
pulseTimes.first() + asTime32(PUBLISH_IN_USE_INTERVAL),
nextPublishTime
);
}
}
}
}
return nextPublishTime;
}
bool inNetworkProblemDelay() {
return networkProblemRetryDelay > 0;
}
void onPublishSuccess() {
ledSignalNetworkProblem.setActive(false);
retainedData.pendingPublishFailureCount = 0;
networkProblemRetryDelay = 0;
// Publish at most every 5 seconds (e.g., when recovering after network outage)
earliestNextPublishTime = nowTime() + asTime32(PUBLISH_MIN_INTERVAL);
}
void onPublishFailure() {
ledSignalNetworkProblem.setActive(true);
retainedData.pendingPublishFailureCount += 1;
// Increase delay: 1 minute - 4 hours, with exponential backoff on repeated failures.
networkProblemRetryDelay = constrain(
networkProblemRetryDelay * 2,
asTime32(NETWORK_PROBLEM_INITIAL_DELAY),
asTime32(NETWORK_PROBLEM_MAX_DELAY));
earliestNextPublishTime = nowTime() + networkProblemRetryDelay;
}
void publishData() {
// Collect metering data (unless previous data still pending)
if (!hasPendingPublish()) {
// This is the "reliable message delivery" portion of the data.
// Once captured, we will keep trying to publish it until successful.
// (Other data--like device battery level--is updated on each publish
// attempt, because we don't need reliable delivery for it.)
ATOMIC_BLOCK() {
retainedData.pendingPublishTime = nowTime();
retainedData.pendingPublishPulseCount = currentPulseCount;
retainedData.pendingPublishFailureCount = 0;
// Move pulseTimes for this publish into pendingPublishPulseTimes
for (auto& pendingPulseTime: retainedData.pendingPublishPulseTimes) {
if (pulseTimes.isEmpty() || pulseTimes.first() > pendingPublishTime) {
pendingPulseTime = INVALID_TIME;
break;
}
pendingPulseTime = retainedData.pulseTimes.shift();
}
publishImmediately = false;
}
}
if (nowTime() < earliestNextPublishTime) {
// Try again later. (Delay for burst control or connectivity issues.)
return;
}
// Connect to network
if (!WiFi.ready()) {
WiFi.connect();
const std::chrono::milliseconds timeout(NETWORK_CONNECT_TIMEOUT);
if (!waitFor(WiFi.ready, timeout.count())) {
onPublishFailure();
return;
}
}
// Connect to cloud
if (!Particle.connected()) {
Particle.connect();
const std::chrono::milliseconds timeout(CLOUD_CONNECT_TIMEOUT);
if (!waitFor(Particle.connected, timeout.count())) {
onPublishFailure();
return;
}
}
// Capture current device status
WiFiSignal signal = WiFi.RSSI(); // only valid when WiFi on
float wifiRSSI = signal.getStrengthValue(); // dBm [-90, 0]
float wifiSNR = signal.getQualityValue(); // dB [0, 90]
float wifiStrength = signal.getStrength(); // % [0, 100]
float wifiQuality = signal.getQuality(); // % [0, 100]
float batteryVoltage = batteryMonitor.getVCell(); // V
float batteryCharge = batteryMonitor.getSoC(); // % [0, 100] nominally, but can report higher
// Format JSON event data
static std::array<char, particle::protocol::MAX_EVENT_DATA_LENGTH> dataBuf;
JSONBufferWriter writer(dataBuf.data(), dataBuf.size() - 1);
writer.beginObject();
{
writer.name("t").value(pendingPublishTime); // timestamp of meter data capture
writer.name("at").value(nowTime()); // actual now
writer.name("seq").value(publishCount);
writer.name("per").value(pendingPublishTime - lastPublishTime);
writer.name("cur").value(pendingPublishPulseCount);
writer.name("lst").value(lastPublishPulseCount);
writer.name("use").value(pendingPublishPulseCount - lastPublishPulseCount);
writer.name("sig").value(wifiRSSI);
writer.name("snr").value(wifiSNR);
writer.name("sgp").value(wifiStrength);
writer.name("sqp").value(wifiQuality);
writer.name("btv").value(batteryVoltage);
writer.name("btp").value(batteryCharge);
writer.name("try").value(pendingPublishFailureCount);
writer.name("pts").beginArray();
{
// Encode pulseTimes as deltas from previous values.
// First pulseTime is encoded as delta from last publish.
time32_t previousTime = lastPublishTime;
for (const auto& pulseTime: pendingPublishPulseTimes) {
if (pulseTime == INVALID_TIME) {
break;
}
writer.value(pulseTime - previousTime);
previousTime = pulseTime;
}
}
writer.endArray();
writer.name("v").value(WATERBOT_VERSION);
}
writer.endObject();
writer.buffer()[std::min(writer.bufferSize(), writer.dataSize())] = '\0';
// assert(writer.dataSize() < writer.bufferSize()); // ???
// Publish the event
if (Particle.publish(EVENT_DATA, dataBuf.data(), WITH_ACK)) {
ATOMIC_BLOCK() {
retainedData.lastPublishTime = pendingPublishTime;
retainedData.lastPublishPulseCount = pendingPublishPulseCount;
retainedData.publishCount += 1;
retainedData.pendingPublishTime = INVALID_TIME; // no longer pending
}
onPublishSuccess();
Particle.publishVitals(particle::NOW); // blocks
} else {
onPublishFailure();
}
}
// Cloud function: arg int newPulseCount
int setReading(String args) {
long newPulseCount = args.toInt();
if (newPulseCount < 0 || (newPulseCount == 0 && !args.equals("0"))) {
return -1;
}
ATOMIC_BLOCK() {
retainedData.currentPulseCount = newPulseCount;
retainedData.pulseTimes.clear();
publishImmediately = true;
}
return 0;
}
// Cloud function
int sleepNow(String args) {
stayAwakeUntilTime = 0;
return 0;
}
// Cloud function
int publishNow(String args) {
publishImmediately = true;
return 0;
}
// Cloud function: arg 1=internal, 2=external, anything else=auto
// (Antenna is restored to auto when reset button pressed)
int selectAntenna(String args) {
WLanSelectAntenna_TypeDef antennaType;
switch (args.toInt()) {
case 1:
antennaType = ANT_INTERNAL;
break;
case 2:
antennaType = ANT_EXTERNAL;
break;
default:
antennaType = ANT_AUTO;
break;
}
WiFi.selectAntenna(antennaType);
return 0;
}
time32_t calcSleepTime() {
// Returns number of seconds to sleep -- or zero if we shouldn't sleep yet
if (pulseDebounceTimer.isActive()) {
return 0; // stay awake to complete pulse detection
}
if (pulsesToSignal > 0) {
return 0; // stay awake to complete signaling
}
time32_t now = nowTime();
if (now < stayAwakeUntilTime) {
return 0; // stay awake after reset
}
// Sleep until time for next publish
time32_t nextPublishTime = calcNextPublishTime();
time32_t sleepTime = std::max(nextPublishTime, earliestNextPublishTime) - now;
return sleepTime < asTime32(MIN_SLEEP_INTERVAL) ? 0 : sleepTime;
}
void disconnectCleanly() {
// Disconnect from Particle Cloud and turn off WiFi power cleanly.
Particle.disconnect(); // relies on CloudDisconnectOptions.graceful (see setup)
waitUntil(Particle.disconnected); // uses CloudDisconnectOptions.timeout (see setup)
WiFi.off();
}
void sleepDevice(time32_t sleepSecs) {
// Enter ultra low power mode (after finishing any cloud communication),
// waking on PIN_PULSE_SWITCH or after sleepSecs secs.
const std::chrono::seconds sleepDuration(sleepSecs);
System.sleep(
SystemSleepConfiguration()
.mode(SystemSleepMode::ULTRA_LOW_POWER)
.gpio(PIN_PULSE_SWITCH, FALLING)
.duration(sleepDuration)
);
}
void displayPulseSignals() {
// This runs in a separate thread, so it isn't blocked
// by long running cloud functions in the main thread.
// (Note that delay() yields to other threads.)
while (true) {
if (pulsesToSignal > 0) {
digitalWrite(PIN_LED_SIGNAL, HIGH);
delay(SIGNAL_MSEC_ON);
ATOMIC_BLOCK() {
pulsesToSignal -= 1;
}
digitalWrite(PIN_LED_SIGNAL, LOW);
delay(SIGNAL_MSEC_OFF);
} else {
delay(50ms);
}
}
}
void setup() {
validateRetainedData();
pinMode(PIN_LED_SIGNAL, OUTPUT);
digitalWrite(PIN_LED_SIGNAL, LOW);
pinMode(PIN_PULSE_SWITCH, INPUT_PULLUP);
attachInterrupt(PIN_PULSE_SWITCH, pulseISR, FALLING);
if (!pulseSignalThread) {
pulseSignalThread = new Thread(
"pulseSignal",
displayPulseSignals,
OS_THREAD_PRIORITY_DEFAULT,
256U // only need a tiny stack
);
}
batteryMonitor.begin();
if (System.resetReason() == RESET_REASON_POWER_DOWN) {
batteryMonitor.quickStart();
}
Particle.function(FUNC_SET_READING, setReading);
Particle.function(FUNC_PUBLISH_NOW, publishNow);
Particle.function(FUNC_SLEEP_NOW, sleepNow);
Particle.function(FUNC_SELECT_ANTENNA, selectAntenna);
if (System.resetReason() == RESET_REASON_PIN_RESET) {
WiFi.selectAntenna(ANT_AUTO);
}
Particle.setDisconnectOptions(
CloudDisconnectOptions()
.graceful(true) // required for disconnectCleanly
.timeout(CLOUD_DISCONNECT_TIMEOUT)
);
Particle.connect();
waitUntil(Particle.connected);
ledSignalNetworkProblem.setActive(false);
// Most of our logic depends on valid RTC.
// If we lost track of time while powered down, restore it now.
if (!Time.isValid()) {
Particle.syncTime();
waitUntil(Particle.syncTimeDone);
ledSignalTimeInvalid.setActive(false);
}
if (lastPublishTime == INVALID_TIME) {
// If we don't know lastPublishTime (first run, retainedData layout change),
// initialize it to power-up time so next reported usageInterval is reasonable.
retainedData.lastPublishTime = nowTime() - (millis() / 1000);
}
if (System.resetReason() == RESET_REASON_PIN_RESET) {
stayAwakeUntilTime = nowTime() + asTime32(RESET_STAY_AWAKE_INTERVAL);
}
}
void loop() {
// publish
if (nowTime() >= calcNextPublishTime()) {
publishData();
}
// sleep if appropriate
time32_t sleepTime = calcSleepTime();
if (sleepTime > 0) {
disconnectCleanly();
sleepTime = calcSleepTime(); // might have changed while waiting for disconnect
if (sleepTime > 0) {
sleepDevice(sleepTime);
}
}
// wait a little
delay(50ms);
}