Decoding CTS-SAT-1 FrontierSat: Reading Every Byte of a Days-Old CubeSat
Published on May 6, 2026
CTS-SAT-1 (callsign FrontierSat, NORAD 98318) is a 3U CubeSat built by students at the University of Calgary through the CalgaryToSpace program. It launched on May 3, 2026 — three days ago as I write this. Frames appeared on SatNOGS shortly after launch, and this post covers how I decoded all 41 telemetry fields in the beacon frame, the three wrong interpretations I corrected along the way, and how a firmware contributor's decoder independently validated the result.
The frame structure
FrontierSat beacons on 437.385 MHz. Each frame is 134 bytes, with an optional 4-byte CRC32 appended by the AX.100 transceiver making some frames 138 bytes. The layout is straightforward:
- Bytes 0–3: 4-byte CSP (CubeSat Space Protocol) header — sync magic
0xC2A2, flags byte0x8A, reserved0x00 - Bytes 4–133:
COMMS_beacon_basic_packet_t— 130-byte telemetry struct from the OBC firmware
The firmware source is public: firmware/Core/Inc/comms_drivers/comms_tx.h in the CalgaryToSpace/CTS-SAT-1-OBC-Firmware repository on GitHub. Having the struct definition meant I could name every field precisely rather than guessing from observed values alone.
97 frames, three wrong interpretations
The SatNOGS archive had 97 unique frames showing RTC timestamps of April 1 and April 3, 2026. These are actually in-orbit frames from after the May 3 launch — the OBC real-time clock had not yet been synchronised, so it still displayed the date it was last set before the satellite was integrated into the launch vehicle. The team was still working on establishing two-way contact to uplink a time correction. Bytes are not self-documenting, and even with a named struct it is easy to misread fields whose units or encoding are not obvious. Here are the three that took the most work to fix.
Wrong interpretation #1 — uptime in milliseconds, not seconds
The first field after the CSP header is uptime_ms — a 32-bit unsigned integer. I initially read it as seconds. A frame with uptime = 5,272,020 then reported an OBC uptime of 61 days. Another showed 2,430 days — six and a half years. The satellite had not existed that long.
The _ms suffix in the firmware struct was the clue. Divide by 1,000: 5,272,020 ms = 87.9 minutes. 2,430,020,000 ms = 28.1 days — which, combined with in-orbit OBC reboots, is plausible once you notice the EPS has a separate uptime counter explained below.
This applies to last_uplink_ms too (milliseconds since the last received command), and the beacon interval instance — 21 seconds between beacons, not 21 milliseconds.
Wrong interpretation #2 — unix_epoch_time_ms is a u64, not two u32 fields
Bytes 19–26 hold the OBC wall-clock as milliseconds since the Unix epoch. That is a 64-bit value. Reading it as a single u8 (little-endian) gives timestamps around 1,775,000,000,000 ms, which resolve to April 2026 — not the actual current date, but the last time the OBC clock was set before launch integration.
Before I found the firmware struct, I was splitting this 8-byte region into two 32-bit fields. The low 32 bits varied frame to frame. The high 32 bits were a constant 413 across every single frame. A constant that big looked like a firmware build ID or sequence number — I labelled it firmware_id = 413 and moved on. It is not a firmware ID. It is the number of 232-millisecond epochs elapsed since 1970 up to 2026, and it is constant because all 97 frames were recorded within a span of three days.
Wrong interpretation #3 — EPS uptime is not a voltage
Bytes 39–42 hold a 32-bit unsigned integer that reads 207,000–235,000 in the April 1 session and 214,000–215,000 in the April 3 session. Those magnitudes looked like millivolts — a bus voltage in the 207–235 V range, far too high. I labeled the field bus_voltage_mv and flagged it as anomalous.
The firmware struct named this field eps_uptime_sec: the EPS (Electrical Power System) uptime in seconds. The EPS has its own dedicated power rail and does not reset when the OBC reboots. In some frames the OBC had been up for 881 seconds — about 15 minutes — while the EPS showed 214,000 seconds — about 2.5 days. This gap means the OBC had rebooted while the EPS stayed powered, a normal occurrence in early orbit operations. This relationship is encoded as a computed instance in the Kaitai Struct file (obc_rebooted: eps_uptime_sec > uptime_ms / 1000 + 60), letting ground operators detect OBC reboots directly from the beacon stream without any additional telemetry.
What the data actually showed
With the correct field interpretation, the 97 frames paint a consistent picture of the satellite in its early orbit:
| Field | Observed range | Notes |
|---|---|---|
| beacon_count | 1–10,000+ | increments every ~21 seconds |
| unix_epoch_time_ms | ~1,775 × 109 s equivalent | OBC RTC stale — shows April 2026, the last pre-launch sync date |
| eps_battery_voltage_mv | 15,907–16,002 mV | 4S LiPo, 97–100% charge |
| eps_battery_percent | 97–100% | 0 in sessions where EPS reporting inactive |
| eps_battery_temp0_cc | 0x7FFF (constant) | sensor broken on this EPS model — confirmed by team |
| eps_battery_temp1_cc | 814–1133 cC | 8.1–11.3 °C in early orbit |
| obc_temp_cc | 300–2000 cC | 3–20 °C; varies with orbital thermal environment |
| eps_channels_enabled | 0x000000A3 | channels 7/5/1/0 enabled — constant |
| eps_fault_count | 3782 (constant) | accumulated since first power-on |
| reboot_reason | 6 | brownout reset (confirmed via firmware enum) |
| time_sync_source | 5 | EPS RTC (confirmed via firmware enum) |
The battery pack is a 4-cell (4S) LiPo; 15.9–16.0 V corresponds to a nearly full charge (nominal 14.8 V, max 16.8 V), consistent with the solar panels keeping the battery topped up in early orbit. The time_sync_source = eps_rtc and gnss_rx_mode = disabled confirm the team had not yet established two-way contact to synchronise the clock — the OBC RTC still shows the pre-launch date.
Kaitai Struct — 41 fields, 0 unparsed bytes
The complete frame is described as a .ksy file covering 40 sequential fields and 12 computed instances. The flat layout (no nested types) keeps it simple and readable. In the Kaitai Web IDE, both a 134-byte and a 138-byte frame (with CRC32 stripped) parse cleanly with zero unparsed bytes remaining.
unix_epoch_s = 1775849885 (OBC clock shows 2026-04-01 — stale pre-launch RTC), battery 16.0 V, OBC temp 12.25 °C, 0 unparsed bytes
A few things worth noting about the .ksy:
sync_magicis declared asu2be(big-endian) even though the rest of the frame is little-endian — the two sync bytes are a big-endian value by convention in the CSP headerunix_epoch_time_msisu8— the 64-bit Kaitai type — and the instanceunix_epoch_sdivides by 1000 to give Unix seconds- Temperature fields ending in
_ccare centi-Celsius; divide by 100 for °C. Power fields ending in_cware centi-Watts. - The top-level
doc:anddoc-ref:keys in the KSY are valid for submission to satnogs-decoders but cause "unknown key" errors in the Kaitai Web IDE's bundled compiler — remove them when testing interactively
Cross-validation with the firmware author's decoder
On May 4, 2026 — one day after launch — parker-research, a contributor to the CTS-SAT-1-OBC-Firmware repository, submitted MR #517 to the satnogs-decoders repository, adding a ksy/frontiersat.ksy file. The field names in their submission are identical to the names in the firmware struct I had derived independently. This was a strong cross-check: arriving at the same field map through two independent paths confirms the layout.
Their MR adds value our flat decoder does not have: eight rich enumerations that decode every observed state value. A selection:
reboot_reason = 6→brownout_reset(STM32 reset cause)time_sync_source = 5→eps_rtccts1_op_state = 2→nominal_with_radio_txrbf_pin_state = 1→flying(Remove-Before-Flight pin pulled)gnss_rx_mode = 2→disabled
Their documentation also explicitly notes that eps_battery_temperature_0_cc is "broken on our EPS model" — the official explanation for why we always see 0x7FFF in that field.
What's open
- The
.ksyfile covers all 40 fields in the 134-byte frame (CSP header +COMMS_beacon_basic_packet_t) - Computed instances include
obc_rebooted— a diagnostic that detects OBC reboots from the EPS/OBC uptime gap - The Python decoder processes hex frames from SatNOGS transcripts and outputs all fields with physical units
- MR #517 by parker-research is the authoritative submission to satnogs-decoders — it includes the full enumeration set and CRC32 parsing
cts1_op_state,mpi_rx_mode,mpi_transceiver_state, andmpi_stop_reasonenumeration values are in the firmware repo but not seen across this specific set of 97 pre-launch frames
CTS-SAT-1 is in orbit as of this writing. The first in-orbit frames from SatNOGS observers will be a different dataset to the ground tests — the GNSS receiver will be active, temperatures will swing with orbital period, and the EPS will be running from solar panels rather than bench power. The decoder is ready for it.
73, Sunil