Wednesday, April 20, 2022

Allowing LMIC to sleep soundly on a Feather bed

The previous two posts have provided most of what is required to allow a sketch to sleep between LMIC tasks.

The RTC is used to set an alarm to wake some number of seconds in the future, to go into standby mode, and to receive the alarm interrupt that brings the MCU out of standby. The fact it is being set to something like the current time is not relevant to these operations, but is useful if you wish to send your uplinks roughly aligned to certain times such as each hour, on the hour.

Then details were given on how to provide LMIC with a source of ticks that keeps running in standby mode, using the same oscillator the RTC uses, so there are no additional power requirements. Unfortunately that requires changes to one of the lowest levels of LMIC, making it incompatible with any other version and the Arduino library management system. The current structure of the Arduino port of LMIC makes it impossible to do this in a clean and compatible manner.

This post demonstrates how to use these features and modifications to go into standby mode between uplinks.

To begin with, if the sketch wishes to write messages to a serial port it must use Serial1, because Serial does not work after coming out of standby mode. So a USB-UART converter is required, attached to the RX/TX pins on the Feather. Serial1 does not need to reinitialised when waking from standby, it just keeps working.

Next, one more modification is required to LMIC. This new function allows the sketch to query when the next LMIC job is due to run.

Add the function declaration to oslmic.h, under the declaration for os_queryTimeCriticalJobs:

#ifndef os_getNextDeadline
ostime_t os_getNextDeadline(bit_t *valid);
#endif


And the function implementation goes in oslmic.c:

// Return the tick the next runnable or scheduled job is due.
//
// A runnable job want to run as soon as possible, so 'now' is returned
// if a runnable job exists. Otherwise, if there is a scheduled job the
// tick value for when that job will run is returned.
//
// If there is a runnable or scheduled job then valid will be non-zero
// upon return. If valid is zero, it means nothing is scheduled to run.
ostime_t os_getNextDeadline(bit_t *valid) {
if (OS.runnablejobs) {
*valid = 1;
return os_getTime();
}

if (OS.scheduledjobs) {
*valid = 1;
return OS.scheduledjobs->deadline;
}

*valid = 0;
return 0;
}


The sketch can now decide if it is reasonable to sleep, and if so for how long.

It seemed the safest algorithm to use was:

  • Do not sleep until the join is successful - ie busy-loop os_runloop_once() so LMIC has every chance to do the join.

  • Only consider sleeping between when one uplink/downlink completes and the next time sensor measurements and an uplink is due. This means go back to busy-looping os_runloop_once() after waking up on the assumption LMIC wants to do something soon.

  • Do not bother sleeping for less than a couple of seconds. Uncertainty in how the one-second-resolution time in the RTC lines up with the next job time means it is safer to ensure the sketch gives at least a one-second leeway.


Rather than picking out the many standby related parts of the sketch, here it is in total. It is a modification of the standard OTAA example.

#define serial Serial1

#include <lmic.h>
#include <hal/hal.h>
#include <RTCZero.h>

RTCZero rtc;

// This flag is used to avoid sleeping before the node had joined the
// network. Not very efficient but it ensures sleeping cannot get in
// the way of a join.
static bool joined = false;

// This flag is used to decide whether to make a network time request when
// an uplink is set. The network time request adds 1 byte to the payload.
// Once a network time response has been received this is set to true.
// It could be reset once a day or so to try and keep clock drift in check.
static bool timeOk = false;

// This flag is used to decide whether to see if there is time to sleep
// before the next LMIC job is due to run. Essentially, it is set to true
// when an uplink/downlink has completed because it is likely that sleeping
// is possible at that point, and then set to false when going to sleep on
// the assumption that when the node wakes up it should busy-loop
// os_run_once() because LMIC will be wanting to run a job soon.
static bool check_deadline = false;

// A buffer for printing log messages.
static constexpr int MAX_MSG = 256;
static char msg[MAX_MSG];

// A printf-like function to print log messages prefixed by the current
// LMIC tick value. Don't call it before os_init();
void log_msg(const char *fmt, ...) {
snprintf(msg, MAX_MSG, "% 012ld: ", os_getTime());
serial.write(msg, strlen(msg));
va_list args;
va_start(args, fmt);
vsnprintf(msg, MAX_MSG, fmt, args);
va_end(args);
serial.write(msg, strlen(msg));
serial.println();
}

static const uint8_t PROGMEM DEVEUI[8]={ ... };
static const uint8_t PROGMEM APPEUI[8]={ ... };
static const uint8_t PROGMEM APPKEY[16]={ ... };

void os_getArtEui (u1_t* buf) { memcpy_P(buf, APPEUI, 8);}

// This should also be in little endian format, see above.
void os_getDevEui (u1_t* buf) { memcpy_P(buf, DEVEUI, 8);}

// This key should be in big endian format (or, since it is not really a
// number but a block of memory, endianness does not really apply). In
// practice, a key taken from the TTN console can be copied as-is.
void os_getDevKey (u1_t* buf) { memcpy_P(buf, APPKEY, 16);}

static uint16_t counter = 0;
static osjob_t sendjob;

// Schedule TX every this many seconds (might become longer due to duty
// cycle limitations).
const unsigned TX_INTERVAL = 1800;

// Pin mapping for Adafruit Feather M0 LoRa, etc.
// /!\ By default Adafruit Feather M0's pin 6 and DIO1 are not connected.
// Please ensure they are connected.
const lmic_pinmap lmic_pins = {
.nss = 8,
.rxtx = LMIC_UNUSED_PIN,
.rst = 4,
.dio = {3, 6, LMIC_UNUSED_PIN},
.rxtx_rx_active = 0,
.rssi_cal = 8, // LBT cal for the Adafruit Feather M0 LoRa, in dB
.spi_freq = 8000000,
};

void onEvent (ev_t ev) {
switch(ev) {
case EV_JOINING:
log_msg("EV_JOINING");
break;
case EV_JOINED:
log_msg("EV_JOINED");

// Disable link check validation (automatically enabled
// during join, but because slow data rates change max TX
// size, we don't use it in this example.
LMIC_setLinkCheckMode(0);
joined = true;

// Send the first uplink immediately.
do_send(&sendjob);
break;
case EV_JOIN_FAILED:
log_msg("EV_JOIN_FAILED");
break;
case EV_TXCOMPLETE:
digitalWrite(LED_BUILTIN, LOW);
log_msg("EV_TXCOMPLETE (includes waiting for RX windows)");

// The uplink/downlink is done so now it is ok to check for
// a sleep window.
check_deadline = true;
break;
case EV_TXSTART:
digitalWrite(LED_BUILTIN, HIGH);
log_msg("EV_TXSTART");
break;
case EV_JOIN_TXCOMPLETE:
log_msg("EV_JOIN_TXCOMPLETE: no JoinAccept");
break;
}
}

void do_send(osjob_t* j) {
// Schedule next transmission so it is TX_INTERVAL from now, not from
// when this packet has finished sending.
os_setTimedCallback(&sendjob, os_getTime()+sec2osticks(TX_INTERVAL),
do_send);

// Check if there is not a current TX/RX job running
if (LMIC.opmode & OP_TXRXPEND) {
log_msg("OP_TXRXPEND, not sending");
} else {
// Keep asing for the time until the server provides it.
if ( ! timeOk) {
log_msg("Adding DeviceTimeReq MAC command to uplink.");
LMIC_requestNetworkTime(lmic_request_network_time_cb, 0);
}

// Prepare upstream data transmission at the next possible time.
counter++;
LMIC_setTxData2(1, (unsigned char *)&counter, sizeof(counter), 0);
log_msg("Packet queued");
}
}

void setup() {
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, LOW);

serial.begin(115200);

// LMIC init
os_init();
// Reset the MAC state. Session and pending data transfers will be
// discarded.
LMIC_reset();

LMIC_setLinkCheckMode(0);

// Reset the time in the RTC so it doesn't have random values before
// the network time has been received.
rtc.begin(true);

memset(msg, 0, sizeof(msg));

// Ask LMIC to start the join process.
LMIC_startJoining();
}

void printTime() {
log_msg("RTC time: %02d/%02d/%02d %02d:%02d:%02d",
rtc.getDay(), rtc.getMonth(), rtc.getYear(),
rtc.getHours(), rtc.getMinutes(), rtc.getSeconds());
}

static lmic_time_reference_t lmicTimeRef;

void lmic_request_network_time_cb(void * pUserData, int flagSuccess) {
if (flagSuccess != 0 && LMIC_getNetworkTimeReference(&lmicTimeRef)) {
timeOk = true;

// This is probably wrong, but it gets the node to within a couple
// of minutes. There must be a means of using tLocal and the current
// tick count to get a more accurate adjustment.

// GPS time is reported in seconds, and RTCZero also works with
// seconds in epoch values.
// 315964800 is the number of seconds between the UTC epoch and the
// GPS epoch, when GPS started.
// 18 is the GPS-UTC offset.
constexpr uint32_t adjustment = 315964800 - 18;
uint32_t ts = lmicTimeRef.tNetwork + adjustment;

// Adding rxDelay because the server sets the timestamp value to
// when it receives the uplink with the time request in it, but
// doesn't send the response until rxDelay seconds later.
ts = ts + LMIC.rxDelay;

// Add a second to cover RX1.
ts = ts + 1;

rtc.setEpoch(ts);

log_msg("tlocal = %ld, tNetwork = %lu, adjusted to UTS epoch = %lu",
lmicTimeRef.tLocal, lmicTimeRef.tNetwork, ts);
printTime();
}
}

static int32_t delta_osticks = -1;
static int32_t delta_seconds = -1;

void set_alarm() {
int32_t ss = (int32_t)rtc.getSeconds();
int32_t mm = (int32_t)rtc.getMinutes();
int32_t hh = (int32_t)rtc.getHours();

log_msg("Time now = %02d:%02d:%02d", hh, mm, ss);

int32_t delta = delta_seconds;
int32_t hh_delta = delta / 3600; delta -= (hh_delta * 3600);
// Will always be less than 1 hour.
int32_t mm_delta = delta / 60; delta -= (mm_delta * 60);
// Will always be less than 1 minute.
int32_t ss_delta = delta;

ss += ss_delta;
if (ss > 60) {
ss = ss % 60;
mm_delta++;
}

mm += mm_delta;
if (mm > 60) {
mm = mm % 60;
hh_delta++;
}

hh = (hh + hh_delta) % 24;

log_msg("Delta(s) = %d, wake at %02d:%02d:%02d",
delta_seconds, hh, mm, ss);

rtc.setAlarmTime((uint8_t)(hh & 0xff),
(uint8_t)(mm & 0xff),
(uint8_t)(ss & 0xff));
rtc.enableAlarm(RTCZero::MATCH_HHMMSS);
}

void loop() {
os_runloop_once();

// If LMIC hasn't joined yet, or is in a TX/RX state,
// let LMIC process and return without doing anything else.
if ( ! (joined || check_deadline)) {
return;
}

// This is used to decide whether to sleep so initialise it to
// a known value.
delta_seconds = -1;

// If an uplink/downlink has just finished, see if there is time to sleep
// before LMIC wants to do anything else.
if (check_deadline) {
check_deadline = false;
bit_t have_deadline = 0;
ostime_t timestamp = os_getTime();
ostime_t deadline = os_getNextDeadline(&have_deadline);

if (have_deadline) {
log_msg("Next deadline: %ld", deadline);
delta_osticks = deadline - timestamp;
delta_seconds = osticks2ms(delta_osticks)/1000;
log_msg("Delta from now: %ld, %ld s",
delta_osticks, delta_seconds);
} else {
// This likely means there is no user-level job scheduled
// and in this sketch that means no other job will ever be
// scheduled so this is the end of the line, but the sketch
// may as well keep busy-looping.
log_msg("No deadline");
}
}

// Given the RTC is used for the alarm and it has a 1 second granularity
// it seems prudent to provide a buffer for waking up in time to allow
// LMIC to run the next task.
if (delta_seconds > 2) {
delta_seconds--;

// Set the alarm in the RTC.
set_alarm();

// Ensure the log output is visible.
serial.flush();

// Don't try to sleep again after waking up, go back to busy-loop
// operation.
check_deadline = false;

// Go into standby mode until woken by an interrupt, eg the RTC alarm.
rtc.standbyMode();

// Disable the alarm in case it was set to some short interval and
// LMIC tasks will run for longer than that. It probably wouldn't
// cause trouble but may as well be sure.
rtc.disableAlarm();
}
}



No comments: