# Time

> Source: https://www.ymotongpoo.com/books/tinygo-otel-esp32/35-clock/


When an ESP32-S3 program written in TinyGo reads the time right after boot, it gets January 1, 1970, the start of Unix time. On a server, the OS sets the clock for you, so you do not think about it. But data that you send over [OTLP](https://opentelemetry.io/docs/specs/otlp/) needs the real time. This chapter first confirms why the time is necessary. It then looks at the SNTP client that I wrote to set the clock on the device.

## Why OTLP needs the time

An OTLP data point has `time_unix_nano`, the time of the observation. It holds the nanoseconds since January 1, 1970 as a uint64, and OTLP/JSON writes it as a string (Chapter 6). If this value stays in 1970, a backend such as Grafana treats the data as 56 years old. The dashboard then shows nothing in its time range.

A cumulative counter also needs a second time, `start_time_unix_nano`. A cumulative counter is a metric that sends the total since boot every time. On this book's device, the counts of successful and failed exports are cumulative counters. `start_time_unix_nano` is the time when the counter started to count that total. In the [OpenTelemetry metrics data model](https://opentelemetry.io/docs/specs/otel/metrics/data-model/), the backend uses this value to tell a counter reset from a gap in the data.

When the device restarts, its counters start again from 0. A data point that arrives after the restart can have a start time that differs from the earlier ones. In that case, the backend judges that a reset caused the drop in value, and it can compute the increase correctly. If the start time stays the same and there is a gap between values, the backend treats the data in that gap as data that never arrived. If every start time sticks near January 1, 1970, the backend has no information to tell a reset from a gap.

This book's device records the time right after it boots and sets the clock, and it keeps that time as the start time. It attaches the start time to every data point of a cumulative counter. A gauge (a metric that sends the value at that moment) has no interval, so the device attaches no start time to it.

```go
// A cumulative counter needs a real start time or the backend cannot tell
// a reset from a gap. espradio does not sync the clock on its own, so ask
// an SNTP server directly. See sntp.go for why.
start := syncClockOrWarn(l)
startNanos := uint64(start.UnixNano())
```

## Why espradio does not set the clock

[lneto](https://github.com/soypat/lneto) is the TCP/IP stack that the Wi-Fi driver [espradio](https://github.com/tinygo-org/espradio) uses. It implements NTP, a protocol to set the clock. Even so, if you connect through the exported API of espradio v0.3.0, the clock stays in 1970.

The cause is in the `netlink` package of espradio. `netlink.NetConnect` connects to Wi-Fi. When it builds the stack configuration, it does not set the NTP server address (`StackConfig.NTPServer`). The address stays at the zero value, so the stack never starts NTP synchronization. To call `DoNTP`, the method that runs NTP, you must reach the internal stack. But `rstack()`, the function that reaches it, is unexported. The clock is correct in the espradio [example](https://github.com/tinygo-org/espradio/tree/main/examples/http-no-allocs) because the example builds the stack directly without `netlink` and calls `DoNTP` itself.

On the real device, I connected with `netlink` and waited 30 seconds. The clock was still in 1970.

```
warning: clock never synchronised; timestamps will be wrong
clock ready: 1970-01-01 00:00:33.0411515 +0000 UTC
```

The library contains the code that sets the clock, but you cannot call it from outside. So I left espradio unchanged and implemented a protocol to query the time myself.

## SNTP packets

**SNTP** (Simple Network Time Protocol) is the part of NTP that queries the time once and receives the answer. [RFC 4330](https://www.rfc-editor.org/rfc/rfc4330) defines it[^rfc5905]. NTP processes the responses from several servers statistically to reduce the error. A device only needs timestamps on its telemetry that are correct to the second, and SNTP is enough for that.

[^rfc5905]: [RFC 5905](https://www.rfc-editor.org/rfc/rfc5905), which defines NTPv4, obsoletes RFC 4330. The packet format has not changed, and the validation rules in this book's implementation follow the text of RFC 4330.

An SNTP request and an SNTP response are both 48-byte UDP packets, exchanged with port 123 on the server. The client writes only the first byte of the request and leaves the rest at 0. The first byte packs three fields, from the most significant bit: 2 bits of LI (leap indicator, a warning of a leap second), 3 bits of version number, and 3 bits of mode. A client request has LI 0, version 4, and mode 3 (client), so the value is 0x23.

```go
// ClientV4 is the first byte of a client request: LI=0, VN=4, Mode=3.
//
// It is built from its fields rather than written as a literal. The obvious
// literal 0x1B is version *3*; writing it and calling it v4 in a comment is
// exactly the mistake this constant prevents.
const ClientV4 = byte(0<<6 | 4<<3 | 3)
```

The value 0x1B, which SNTP examples often use, is a version 3 request. Many servers answer either value. But if you write the value as a literal and write "version 4" in a comment, you cannot notice when the comment and the value disagree. This book's implementation builds the value from its fields, and a test confirms that the version is 4.

From the response, the device uses only the time when the server sent the response (Transmit Timestamp). The 4 bytes at offset 40 hold the seconds since January 1, 1900. The 4 bytes at offset 44 hold the fraction of a second, in units of 1/2^32 second. Both are big-endian. The client does not correct for the round-trip time. As a result, the clock runs behind by about half the round-trip time. I judged this acceptable for the timestamps of telemetry that the device sends every 10 seconds.

## Validating the response

Suppose that the client uses packets that arrive on the UDP socket without validation. A corrupted or unrelated packet can then move the clock, and the timestamps of all later telemetry shift. So `sntp.Parse` discards the following responses, according to the client validation rules in section 5 of RFC 4330.

| Condition | Reason to discard |
| --- | --- |
| Shorter than 48 bytes | It cannot be read as an SNTP header |
| The mode is neither 4 (server) nor 5 (broadcast) | It is not a response from a server |
| The version is neither 3 nor 4 | There is no guarantee that the time format is the same |
| LI is 3 | The clock of the server is not synchronized |
| The stratum is 0 | It is a Kiss-o'-Death, which carries an error instead of the time |
| Transmit Timestamp is 0 | The server does not have the time |

The stratum is a 1-byte value that shows how many steps the server is from a reference clock. The name for a response with the value 0 is **Kiss-o'-Death**. A server sends one, for example, to ask a client to query less often. The time fields in such a response have no meaning, so the client cannot use them to set the clock. An LI of 1 or 2 is only a warning of a leap second, and the time is valid. An LI of 3 means that the server's own clock is not yet synchronized, so the client discards the response.

The client accepts version 3 because some servers answer a version 4 request with version 3. Both versions use the same time format. If the client rejected version 3, it would only lose a usable time source.

```go
mode := p[offsetLIVNMode] & 0x7
if mode != modeServer && mode != modeBroadcast {
	return 0, 0, ErrNotServer
}
// Accept 3 and 4: a v3 server may answer a v4 request, and the timestamp
// format is identical.
if v := (p[offsetLIVNMode] >> 3) & 0x7; v != 3 && v != 4 {
	return 0, 0, ErrWrongVersion
}
if p[offsetLIVNMode]>>6 == 3 {
	return 0, 0, ErrUnsynchronized
}
// Stratum 0 is a kiss-of-death: the packet carries an error code in the
// reference identifier, not a usable time.
if p[offsetStratum] == 0 {
	return 0, 0, ErrKissOfDeath
}
```

The tests check the validation rules. They change a correct response one byte at a time and confirm that the parser rejects each result. One test rejects a packet that echoes the request back, with the mode still at 3. That test pins down the guarantee that unrelated packets do not move the clock.

## The 2036 rollover

The SNTP seconds field is a 32-bit unsigned integer, so the count wraps around about 136 years after 1900. The wrap happens on February 7, 2036. If you always read the seconds as counting from 1900, every time from February 7, 2036 onward goes back to 1900.

Section 3 of RFC 4330 shows how to resolve this ambiguity with the most significant bit. If the most significant bit is set, you read the time as between 1968 and 2036. If it is not set, you read the time as between 2036 and 2104. You do not need times before 1968, so each value maps to one time until 2104.

```go
// toUnix converts an era-aware NTP timestamp to Unix time.
func toUnix(secs, frac uint32) (int64, int64) {
	s := int64(secs) - epochOffset
	if secs&0x80000000 == 0 {
		// Era 1: 2036-02-07 onwards.
		s = int64(secs) + era1Offset - epochOffset
	}
	// The fraction is in units of 2^-32 seconds.
	return s, int64(frac) * 1e9 >> 32
}
```

`epochOffset` is 2,208,988,800 seconds, the difference between 1900 and 1970. `era1Offset` is 2^32 seconds, one full cycle of 32 bits. A test builds a time one hour after the rollover on February 7, 2036. It then checks that the parser reads the time as 2036, not 1900. This book's device is unlikely to still run in 2036. But when the packet parsing is testable on the host, you can pin down rollover conditions like this one without the real device.

## Setting the clock

On the device, the code resolves the name of the NTP server with DNS. It then sends a request over UDP and passes the response to `sntp.Parse`. For name resolution, I used `GetHostByName`, an exported API of espradio. To avoid a dependency on one specific server, I chose `pool.ntp.org` from the [NTP Pool Project](https://www.ntppool.org/en/) as the server to query. The timeout is 5 seconds, with up to 3 attempts.

The TinyGo runtime for microcontrollers has no function that sets the time directly. Instead, it has `runtime.AdjustTimeOffset`, which adjusts an offset that the runtime adds to the time since boot. So the code computes the difference between the received time and the current clock, and passes that difference.

```go
// applyClock moves the runtime clock to t.
//
// TinyGo has no settimeofday. runtime.AdjustTimeOffset shifts the monotonic
// clock's mapping to wall time by a delta, so the delta is computed against
// what the clock currently reads.
func applyClock(t time.Time) {
	runtime.AdjustTimeOffset(int64(t.Sub(time.Now())))
}
```

On the real device, the clock is set right after boot, as soon as the device connects to Wi-Fi.

```
sntp: resolved pool.ntp.org to 167.179.119.205
sntp: clock set to 2026-09-15 06:33:52.876544886 +0000 UTC
```

If all 3 attempts fail, the device prints a warning and starts to export with the wrong clock. If the device waited until it could set the clock, it would send nothing, and it would be harder to isolate the cause. If data with an obviously wrong time arrives, you at least know that the network path works. You also know that the problem is in clock synchronization.

I moved the building and parsing of packets into the `sntp` package. The device code keeps only the socket operations. `cmd/device` compiles only with TinyGo build tags. If the packet handling were there, the host tests could not reach it at all.

