0x2A logo

Inside Airgeek: Deriving Timing of Offline Measurements

Written by David Čepelík
Published June 16, 2025

Our upcoming products, Airgeek A1 and the related Airgeek M1, are advanced air quality monitors for home and work use. They grew out of my dissatisfaction with a competitor’s device, which I bought almost two years ago now, and are, in many ways, its exact opposite. By the way, we’ll be crowdfunding soon.

For better or worse, the device I originally bought was very annoying to set up and use. All communication was relayed through the manufacturer’s cloud, adding unnecessary and unpredictable latency to every operation. A configuration change would take anywhere from one to five minutes, depending on the device’s sleep modes.

When designing Airgeek, we made sure it could be used completely offline–as a core feature, not as an afterthought. We use NFC to transfer config and data between the device and the mobile app and, while that poses some challenges on its own1, it provides immediate feedback, leading to a much more pleasant user experience.

Airgeek will also support Matter and Zigbee, two of the most popular protocols used in the smart home ecosystem. But today, let’s focus on the use case when the device is used completely offline, with just NFC and the Android and iOS app. Specifically, let’s take a look at how we derive accurate timing information for measurements taken when the device was offline.

Why we need to know the current time

Before we go into the specifics, here’s a high-level overview of the offline use case.

  1. Every five minutes, the real-time clock subsystem wakes the microcontroller up, and data acquisition starts. Airgeek measures the current temperature, relative humidity, carbon dioxide (CO2) concentration, nitrogen dioxide (NO2) concentration, carbon monoxide (CO) concentration and atmospheric pressure2. Also, it measures particulate matter (PM1, PM2.5 and PM10) six times per day at randomly selected times.3

  2. The LCD is updated according to your configuration, and the measurements are encoded in a data point. A data point consists of a timestamp (that’s what this post is all about), the measured values, and some flags. Data points are first accumulated in a RAM buffer, and once enough of them is accrued to fill a 4k flash page, they are programmed into a NOR flash chip connected via QSPI.

  3. When Airgeek is not measuring data nor communicating via NFC, almost all on-board devices are shut down4 and the microcontroller is in Stop 2 mode, the deepest sleep mode which still retains the contents of RAM.

  4. To read data from the device, you approach it with your smartphone. This wakes the device up and an NFC data transfer begins. The smartphone eventually sends a FetchData request, requesting an interval [Seq, Seq+Len) of measurements. Airgeek responds with a FetchData response containing the requested span of data.

    A diagram showing a simple NFC data exchange between Airgeek and the Airgeek app

  5. To visualize the data, the mobile app calculates histograms with one-hour buckets and displays them as bar charts. This captures the daily trends very well, making it easy to spot any problems.

To perform this aggregation, we need to know when each measurement was taken. This in turn means that Airgeek itself needs to know at what time and date each sample was acquired, so that it can attach timestamps to the measured values. And that’s where the fun begins!

Knowing the current date and time also allows us to display it on the LCD as one of the possible values, which is a nice touch.

The real-time clock peripheral

Airgeek is built around an STM32L4-family microcontroller by the European manufacturer STMicroelectronics. This amazing little chip features several low-power timers, and even an extremely low-power real-time clock (RTC) peripheral, allowing it to keep track of the current date and time without sacrificing battery life.

To give you an example, the RTC peripheral, when used with an external 32.768 kHz crystal-based oscillator, consumes 300 nA at 3 V, or 900 nW. That’s nanowatts. On a single high-quality AA battery, the RTC alone could operate for about 666 years!5

This is great news, and the only remaining task is to set the current date and time periodically. This is easy, too: the mobile app sends the smartphone’s current NTP-synchronized date and time with every data fetch request, allowing Airgeek to adjust its real-time clock during every NFC exchange. Even with infrequent read-outs, this should be enough to keep the RTC reasonably synchronized.

So that’s the end of the story—right? Well, not so fast.

Date and time is hard

The RTC peripheral on the STM32L4 doesn’t keep a traditional Unix timestamp. Instead, it keeps track of calendar time (date-time) of the form:

25-06-14 23:21:42

While this can come in handy in many simpler applications, if you’ve ever worked with dates and times in software, you know they can get very tricky very quickly.

The RTC peripheral is able to insert leap days (Feb 29), and that’s it. But what about leap seconds and daylight saving time? These are decided by policy6, not strict rules. On many Linux distros, there’s a tzdata package which contains a database of clock changes:

~% zdump -v /etc/localtime
/etc/localtime  Thu Jan  1 00:00:00 -2147481748 UT = Thu Jan  1 00:57:44 -2147481748 LMT isdst=0 gmtoff=3464
/etc/localtime  Mon Dec 31 23:02:15 1849 UT = Mon Dec 31 23:59:59 1849 LMT isdst=0 gmtoff=3464
/etc/localtime  Mon Dec 31 23:02:16 1849 UT = Tue Jan  1 00:00:00 1850 PMT isdst=0 gmtoff=3464
/etc/localtime  Wed Sep 30 23:02:15 1891 UT = Wed Sep 30 23:59:59 1891 PMT isdst=0 gmtoff=3464
/etc/localtime  Wed Sep 30 23:02:16 1891 UT = Thu Oct  1 00:02:16 1891 CET isdst=0 gmtoff=3600
/etc/localtime  Sun Apr 30 21:59:59 1916 UT = Sun Apr 30 22:59:59 1916 CET isdst=0 gmtoff=3600
/etc/localtime  Sun Apr 30 22:00:00 1916 UT = Mon May  1 00:00:00 1916 CEST isdst=1 gmtoff=7200
/etc/localtime  Sat Sep 30 22:59:59 1916 UT = Sun Oct  1 00:59:59 1916 CEST isdst=1 gmtoff=7200
/etc/localtime  Sat Sep 30 23:00:00 1916 UT = Sun Oct  1 00:00:00 1916 CET isdst=0 gmtoff=3600
/etc/localtime  Mon Apr 16 00:59:59 1917 UT = Mon Apr 16 01:59:59 1917 CET isdst=0 gmtoff=3600
[...]
/etc/localtime  Sun Mar 31 00:59:59 2024 UT = Sun Mar 31 01:59:59 2024 CET isdst=0 gmtoff=3600
/etc/localtime  Sun Mar 31 01:00:00 2024 UT = Sun Mar 31 03:00:00 2024 CEST isdst=1 gmtoff=7200
/etc/localtime  Sun Oct 27 00:59:59 2024 UT = Sun Oct 27 02:59:59 2024 CEST isdst=1 gmtoff=7200
/etc/localtime  Sun Oct 27 01:00:00 2024 UT = Sun Oct 27 02:00:00 2024 CET isdst=0 gmtoff=3600
/etc/localtime  Sun Mar 30 00:59:59 2025 UT = Sun Mar 30 01:59:59 2025 CET isdst=0 gmtoff=3600
/etc/localtime  Sun Mar 30 01:00:00 2025 UT = Sun Mar 30 03:00:00 2025 CEST isdst=1 gmtoff=7200
/etc/localtime  Sun Oct 26 00:59:59 2025 UT = Sun Oct 26 02:59:59 2025 CEST isdst=1 gmtoff=7200
/etc/localtime  Sun Oct 26 01:00:00 2025 UT = Sun Oct 26 02:00:00 2025 CET isdst=0 gmtoff=3600
/etc/localtime  Sun Mar 29 00:59:59 2026 UT = Sun Mar 29 01:59:59 2026 CET isdst=0 gmtoff=3600
/etc/localtime  Sun Mar 29 01:00:00 2026 UT = Sun Mar 29 03:00:00 2026 CEST isdst=1 gmtoff=7200
/etc/localtime  Sun Oct 25 00:59:59 2026 UT = Sun Oct 25 02:59:59 2026 CEST isdst=1 gmtoff=7200
/etc/localtime  Sun Oct 25 01:00:00 2026 UT = Sun Oct 25 02:00:00 2026 CET isdst=0 gmtoff=3600
[...]
/etc/localtime  Sun Mar 29 00:59:59 2499 UT = Sun Mar 29 01:59:59 2499 CET isdst=0 gmtoff=3600
/etc/localtime  Sun Mar 29 01:00:00 2499 UT = Sun Mar 29 03:00:00 2499 CEST isdst=1 gmtoff=7200
/etc/localtime  Sun Oct 25 00:59:59 2499 UT = Sun Oct 25 02:59:59 2499 CEST isdst=1 gmtoff=7200
/etc/localtime  Sun Oct 25 01:00:00 2499 UT = Sun Oct 25 02:00:00 2499 CET isdst=0 gmtoff=3600
[...]

As you can see, this is far from trivial to get right. That’s why we chose not to implement this in the embedded firmware, and we rely on the smartphone to derive the date and time for each data point during the data readout phase instead. Handling date and time in the Android/iOS app is much easier and much more robust than in the constrained embedded environment.

Further complications

Consider the following additional problems:

In other words, using the real-time clock to derive timestamps for the measurements is a bad idea. What we need is a monotonic counter, and a way to relate its value to the real time.

A simple approach

Here’s a simple approach that almost works. Instead of using the RTC, we’ll be using a low-power timer, clocked from the same 32.768 kHz crystal-based oscillator. Upon reset, this timer has a value of 0, and the value increases by 1000 per second. It’s thus the number of milliseconds since boot. We’ll be calling this the timestamp counter.

We can use the value of the timestamp counter, rounded to the nearest second, as our timestamp. This way, the first measurement will be taken soon after boot, so at roughly time zero, and the next measurement at time approximately 300 (the five-minute mark), and so on. To derive the date-time of a measurement, we’ll send the current value of the counter at the end of each data frame.

A fetch data request/response pair when the first approach is used

Then, for each data point d, we can compute the date-time in the app as (Go-like pseudocode):

d.DateTime = time.Now() - (frame.Timestamp - d.Timestamp) * time.Second

The relationship between timestamps and the real time when using the simple approach

That is, by knowing the current value of the timestamp counter (frame.Timestamp) and by knowing the current date-time (time.Now()), we can derive the date-time of the individual measurements by shifting them to the past according to the value of their timestamp. Easy!

In the example above, the timestamp counter has value 1250 (about 21 minutes after boot) when the fetch response is sent, so that’s the value transmitted in the response frame. When the frame is received, the app can figure out that a value of 1250 (seconds) of the timestamp counter corresponds to the current real time, time.Now(), and perform the straightforward math necessary to derive timing for other data points.

Unfortunately, this simple approach suffers from two annoying problems:

  1. After a reboot it’s not possible to fetch data from before the reboot anymore. This is because the counter used to derive the timestamps is reset to zero during a reset of the microcontroller. This renders past timestamps meaningless, as there’s no way to relate them to the real time anymore.

    If the simple approach is used, it’s not possible to derive timing for measurements taken before a reboot.

    In the example above, a second fetch data request occurs 807 seconds after a reboot. For measurements made after the reboot, we can derive the timing as before. But measurements before the reboot are now meaningless, as we have no way of knowing what the value 1515 of the timestamp counter meant before the reboot.

  2. Reading the same data twice doesn’t produce identical results. The Airgeek clock drifts slightly relative to the device clock (which is furthermore periodically NTP-adjusted), and so the derived date-times will differ by a little every time. This can result in two app instances displaying slightly different measurements for the same time period. Even though this artifact is completely benign—any discrepancies would be tiny—people would probably start to question reliability of the data. I, for one, certainly would.

Both of these problems are unacceptable. Fortunately, we can do better.

A better approach

A simple modification of the above approach is enough:

In other words, the information required to reconstruct the timing of data is persisted with the data, so that future fetches can use them:

The relationship between data points and timing cues when the modified approach is used

In the example above, there are two timing cues. One was constructed during the current fetch request, relating the value 807 of the timestamp counter to the Unix timestamp 1750022841. The other one was constructed before reboot and it relates the value 1250 of the timestamp counter to the Unix timestamp 1750021386. The timing cues are retrieved along with the data points. This makes it possible to derive timing for the past data by using a relevant timing cue:

This solves both problems and more:

Conclusion

We are currently polishing our Android app, and improving timing of the data points was on our roadmap for a long time. We wrote this post primarily to make the proposed solution a bit more precise, and we’ll be using it as a basis for implementation of this mechanism in both our Android and our iOS app.

At the same time, we realized that these are problems that any offline data logging device has to solve, so it makes sense to share how we did it.

In the next post, we’ll take a look at how the data is encoded and compressed, so that we maximize data retention and minimize data transfer times. Stay tuned!

Did I mention we’ll be crowdfunding soon?

  1. NFC is slow! Smartphones only support 53 kb/s = 6.625 kB/s. In practice, the speed tends to be much lower. We managed to get around this limitation by using a simple but powerful compression for the data, which will be the subject of our next post.

  2. In addition to being interesting in its own right, atmospheric pressure is used to compensate CO2, NO2 and CO measurements.

  3. In case you’re wondering why we only make six PMX measurements per day: it’s because we care about the long-term average. We could measure PMX more often, but the long-term average would come out about the same at the expense of a significant reduction in battery life. The long-term background PMX level seems to pose greatest danger to health.

  4. By “shut down”, we mean completely disconnected with decidated load switches. That way, we don’t have to worry about quiescent currents flowing into the sensors, which are often specified with fairly high maxima.

  5. In theory. The battery obviously wouldn’t run for 6 and a half century due to self-discharge and overall degradation. But you get the point.

  6. By the way, the agency in charge of leap seconds is called the International Earth Rotation and Reference Systems Service (IERS), and you just gotta love that name.