Pallet-Attached Cold Chain Logger

This reference application is here to give you ideas and help you get started quickly. It's built on specific hardware that may not match yours, so focus on the sections that fit your use case. If you'd like to talk through your project and whether it's a good fit for Blues, reach out.
Building with an AI assistant? Point it at this accelerator's firmware in firmware/cargo_cold_chain_monitor/ so it can learn how the device works and help you adapt it to your hardware.
This project is a pallet-attached cold chain logger, a supply chain tracking reference design for pharma and food shippers who can't afford to take the reefer unit's built-in telematics on trust. A handful of sensors, a Blues Notecard for Skylo, and a Notecarrier CX give you an independent condition record that the shipper controls. It travels with the cargo through loading docks, over-the-road transit, port staging, and customs DCs, and it sends an alert Note whenever a temperature, humidity, shock, tilt, or cargo-bay-opening threshold is crossed.
1. Project Overview
The problem. When a pharma manufacturer ships a pallet of biologics or a food producer ships a truckload of fresh produce, the temperature data from the reefer unit's built-in telematics has a basic blind spot. Those sensors read the air near the refrigeration unit, not the air where the cargo actually sits. A pallet in the back corner of a 53-foot trailer can run 5–8°F warmer than the reefer controller reports. It gets worse when the trailer sits in a distribution center (DC) yard waiting for a dock door, or when a forklift holds the door open through a long loading sequence.
In regulated industries like pharma and food distribution, "the reefer says it was fine" isn't enough to accept or reject a load. Insurance adjusters, freight claims teams, and quality managers need independent condition data, recorded continuously at the pallet, that the shipper controls instead of the carrier. They also need timestamps they can match to the shipment's BOL (bill of lading) and delivery receipt. When a shipment looks suspect, whoever holds the independent data makes faster and better-informed calls on diversion and acceptance.
This project takes the dedicated temperature logger idea and attaches it to the
pallet, where it records a complete, tamper-evident condition history for the
whole journey. A calibrated PT100 Class A RTD probe (±0.15 °C per IEC 60751)
measures temperature, and the probe supplier provides it with a NIST-traceable
calibration certificate. Every five-minute sample cycle writes a log entry with
a monotonic sequence number, a rolling integrity chain hash, and a boot-segment
counter (boot_seg) that goes up by one every time the device cold boots.
Within each boot_seg, anyone downstream can replay the chain from seq=1
(seed=0) to verify that no records were inserted, deleted, or modified in
transit. The device also tracks shipment state on its own, telling warehouse
dwell apart from active transit and from handling events where the cargo bay is
open. During long dwells it stretches both the summary interval and the
outbound sync cadence, which means fewer satellite sessions and lower NTN data
cost. Whenever the shipment moves from one state to another, it sends a
state-change Note right away.
Why Notecard. In the course of a week, one refrigerated shipment can cross three carriers, two countries, a port container terminal, and an ocean, and wireless coverage looks different in every one of those places. Loading dock interiors are cellular dead zones. Ocean vessels go thousands of miles with no terrestrial coverage at all, while customs DCs and bonded warehouses have spotty cellular and almost never let carriers' IoT devices onto their networks.
New to Blues?
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
Note on reefer standards: This project does not implement J2497, J1939, or proprietary reefer protocols (Carrier DataLink, Thermo King DSR). It's an independent cold-chain monitor that the shipper owns, and it runs alongside carrier telematics instead of integrating with the reefer. That isolation is deliberate. The logger measures cargo-bay conditions, not reefer controller state, and it sends data to shipper systems (TMS, quality, analytics), not carrier networks.
This project uses Notecard for Skylo (NOTE-NBGLWX) (datasheet), an all-in-one module that packs cellular (LTE-M, 2G/3G), satellite (Skylo NTN, non-terrestrial network), and WiFi (2.4 GHz) into a single M.2 form-factor Notecard. It picks the best available radio by itself. In a connected DC where WiFi credentials have been provisioned on the Notecard (see §6 WiFi provisioning), that's WiFi. Over road and rail it's cellular, and where neither is available and the antenna can see the sky, it's satellite. Notes queued in Notecard flash keep their original timestamps and go out intact as soon as any radio comes back, so a coverage gap becomes a store-and-forward gap instead of lost data. There's no SIM provisioning and no per-country certification cycle, since Blues ships pre-certified globally, and nobody has to manage site network credentials at every waypoint.
Deployment scenario. The logger lives in an IP67 enclosure attached to the outside of the pallet, and the enclosure has three sealed openings. One is a PTFE breathable vent on the pallet-facing side wall, which lets the SHT41 sample the surrounding cargo-bay air. A separate 3/8-18 NPT threaded opening on the same wall lets the PT100 probe's 150 mm sensing tip reach into the cargo airstream. The third is a clear polycarbonate lens window on the cargo-bay-facing wall, so the inward-facing VEML7700 can tell when the cargo compartment opens and light gets in. The Skylo-certified multi-band antenna (included with the NOTE-NBGLWX) mounts inside the ABS enclosure with its face pointing up through the lid. That works because ABS is transparent to LTE-M and L-band NTN frequencies.
Satellite connectivity is opportunistic. Skylo NTN uses geostationary satellites and needs a clear view of the sky toward the equator. You'll have a satellite link when the logger is mounted on the exterior top of a pallet in open-air staging, on an open vehicle deck, or in a truck trailer with a composite (non-steel) roof. A closed steel shipping container or enclosed trailer blocks satellite. There, Notes queue in Notecard flash and flush automatically over cellular or WiFi once the pallet reaches terrestrial coverage.
2. System Architecture
Device-side responsibilities. The STM32L433 host on the Notecarrier
CX is bolted to the
outside of the pallet and sleeps in STM32 STOP2 (a few µA) until the Notecard's
ATTN pin wakes it every five minutes. Then it does the whole job in a few
seconds. It reads the PT100/MAX31865 for cargo-air temperature, the SHT41 for
humidity, and the VEML7700 for interior light. Accumulated motion events and
the current orientation come from the Notecard's built-in accelerometer via
card.motion, and those feed the shipment-state model. Next the host checks
five categories of threshold rules (temperature is two-sided, temp_low or
temp_high, which makes six alert types in all), appends a per-sample log
entry, and sends any alert or state-change Notes the rules produce. After that
it goes back to STOP2 with every byte of ColdChainState still in RAM.
Shipment-state model. After each motion and light read, the firmware works out the current shipment state:
- DWELL: confirmed after
dwell_confirm_samplesconsecutive low-motion samples (motion <transit_motion_minper interval). During dwell, the firmware multiplies both thecargo_data.qosummary interval and the Notecard's hub.set outbound cadence bydwell_batch_factor(4× by default). That means fewer summary Notes queued per session and fewer outbound sessions per hour.applyDynamicOutbound()re-issueshub.setwhenever the state moves in or out of DWELL. - IN_TRANSIT: confirmed after
transit_confirm_samplesconsecutive high-motion samples. The normal summary interval applies. - HANDLING: triggered right away when interior lux reaches or exceeds
light_open_lux, which means the cargo door or container lid is open. This state also resets the motion counters.
When the state changes, the firmware sends a cargo_state.qo Note right away
with sync:true, so the remote system hears about the transition in near real
time over whatever radio is available. (It has a compact template, so it can
go over NTN too. See §7.4.) If that first send
fails (say, a transient Notecard I²C issue), the transition stays in
ColdChainState and the firmware retries it on every wake until the Notecard
confirms the note.add. If a newer transition happens while one is still
pending, the newer one replaces it.
Tamper-evident local log. Every sample cycle appends one compact-templated
entry to the cargo_log.qo Notefile. Each entry carries a monotonic sequence
number (seq, incremented before every note.add) and a rolling integrity hash
(chain_crc) computed over the previous hash, the sequence number, the boot
segment, and all sensor readings. It also carries a boot_seg counter that
goes up on every cold boot. That counter stays in host RAM through planned
sleep (STOP2 keeps RAM intact) and is also saved to a Notecard-local Notefile,
chain_boot.dbx, so it survives power loss. Log entries wait for the
regular outbound window instead of syncing right away, so they ride along with
outbound sessions and never cost an extra satellite session per sample.
Every entry includes the _time field. It holds the real epoch once the
Notecard has valid time from the Blues Notehub
cloud service, or 0 as a documented pre-sync sentinel before that. Downstream
consumers should treat _time == 0 as pre-sync and use Notehub's event
receive-time as the best available approximation for those records. A
motion_valid flag (1 = card.motion returned valid data; 0 = card.motion
was unavailable) goes into every entry too. With it, downstream consumers can
tell "no motion occurred" (motion = 0, motion_valid = 1) apart from "motion
data unavailable" (motion = 0, motion_valid = 0), so the per-sample audit
log still means what compliance needs it to mean when the accelerometer
interface is briefly unreachable. A downstream verifier replays the chain
within each boot_seg group from seq=1 (seed=0). A gap in seq within a
segment points to a dropped transmission, and a chain_crc mismatch points to
a modified or inserted record. A new boot_seg value marks the start of a new,
independent chain segment caused by a device cold boot.
Notecard responsibilities. Notecard for Skylo holds outbound
Notes in its on-device flash queue and decides
for itself which radio to use (cellular, NTN satellite, or WiFi), based on
what's reachable from wherever the pallet happens to be. It flushes the queue
on the configured hub.set
outbound cadence (60 minutes by default). Any Note marked sync:true jumps the
queue and opens a session right away on whatever radio is available. In the
other direction, the Notecard pulls down environment
variables
from Notehub, so shipper operations can change threshold values, sample cadence,
or summary cadence mid-route without reflashing firmware.
Notehub responsibilities. Whatever the Notecard sends, over cellular, NTN,
or WiFi, lands in Notehub. Notehub stores it with the
original on-pallet timestamp and runs it through project
routes. The four
Notefiles (cargo_alert.qo, cargo_state.qo, cargo_data.qo, cargo_log.qo)
stay separate so each stream can go to the system that wants it. Alerts and
state changes go to the TMS or on-call channel, summaries go to the cold-chain
analytics platform, and the tamper-evident log entries go to a compliance
archive.
3. Technical Summary
- Notehub: create a Notehub project and copy its ProductUID.
- Wire the bench rig: connect the Notecarrier CX, Notecard for Skylo, MAX31865 on SPI, PT100 probe, SHT41 on I²C, and VEML7700 on I²C. Then add a jumper from the Notecarrier CX's ATTN pin to D5 so the Notecard can wake the host. The full pinout is in §5.
- Edit one line in
firmware/cargo_cold_chain_monitor/cargo_cold_chain_monitor_helpers.h: uncomment the#define PRODUCT_UIDline and set it to your Notehub project's value. - Flash:
The
arduino-cli core install STMicroelectronics:stm32 arduino-cli lib install "Blues Wireless Notecard" "STM32duino Low Power" "STM32duino RTC" "Adafruit MAX31865 library" "Adafruit SHT4x Library" "Adafruit VEML7700 Library" arduino-cli compile -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none firmware/cargo_cold_chain_monitor/ arduino-cli upload -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none -p /dev/cu.usbmodem* firmware/cargo_cold_chain_monitor/usb=noneboard option turns off the USB CDC stack, which has to be off for the host to sleep. Full instructions are in §7.1. - Watch: open Notehub → your project → Events tab.
_session.qoshows up on first contact andcargo_data.qoafter the first summary interval.cargo_log.qoentries arrive in batches with each outbound sync,cargo_state.qoappears on the first confirmed dwell or motion event, and any threshold trip shows up ascargo_alert.qowithin one sample interval of the event that caused it.
First event timeline: After power-up, the device gets the time and checks in with a
_session.qowithin 1–2 minutes over cellular or WiFi, or within several minutes over Skylo NTN with a clear sky. The firstcargo_log.qoentry shows up one sample interval later (~5 minutes). Expect the firstcargo_data.qosummary ~60 minutes after the device gets a valid epoch from Notehub'scard.timeAPI.
Here is a sample Note this device emits:
{
"_time": 1748000000,
"temp_mean_c": 4.3,
"temp_min_c": 3.8,
"temp_max_c": 4.9,
"rh_mean_pct": 62.1,
"rh_min_pct": 60.4,
"rh_max_pct": 63.7,
"lux_max": 0.3,
"motion_total": 2,
"motion_valid": 1,
"samples": 12
}4. Hardware Requirements
| Part | Qty | Rationale |
|---|---|---|
| Notecarrier CX | 1 | Integrated carrier with an onboard STM32L433 host, so you don't need a separate MCU. The host sleeps in STOP2 between samples until the Notecard's ATTN pin wakes it. |
| Notecard for Skylo (NOTE-NBGLWX) | 1 | Single M.2 module with cellular (LTE-M / 2G / 3G), Skylo NTN satellite, and WiFi. It's pre-certified globally, so you skip per-country approval cycles. Datasheet: dev.blues.io/datasheets/notecard-datasheet/note-nbglwx/ |
| Omega PR-21C-3-100-A-1/8-0600-M12-2 PT100 Class A probe + Omega CAL-3 NIST-traceable calibration certificate | 1 | IEC 60751 Class A PT100 (±0.15 °C at 0 °C), 4-wire, with a 3.2 mm (1/8″) × 150 mm (6″) 316L stainless-steel sheath, a 3/8″ NPT process fitting, a 4-pin M12 A-coded output connector, and a −50 °C to 250 °C range. Order the probe together with Omega's CAL-3 option, a NIST-traceable calibration certificate that documents temperature deviation at standard points, performed per ISO 10012-1 / ANSI/NCSL Z540-1. The CAL-3 certificate is what makes the hardware measurement NIST-traceable. A probe shipped without it doesn't meet the calibration-documentation requirement, whatever its accuracy class. The 3/8″ NPT fitting on the probe body threads into a separately drilled and tapped 3/8-18 NPT hole in the pallet-facing side wall, which is a different hole from the M12 PTFE vent hole (see §5). The 4-wire M12 output mates to a field-wireable M12 A-coded female connector or a pre-made M12 patch cable, so the leads route cleanly inside the enclosure to the MAX31865 breakout's RTD terminals. The firmware uses MAX31865_4WIRE, so set the Adafruit #3328 breakout jumper to the 4-wire position (desolder the default 2/3-wire bridge and close the 4-wire pads per the Adafruit product guide). |
| Adafruit MAX31865 RTD Amplifier Breakout (#3328) | 1 | SPI 15-bit ADC built for PT100/PT1000 RTDs. It has hardware fault detection (open-circuit, short-circuit, over/under-voltage) and works with both 2-wire and 4-wire probes. Wire it to the host's hardware SPI bus through the Notecarrier CX's dedicated SPI header pins (CS=D10, CLK=SCK, SDO=MISO, SDI=MOSI). The Adafruit #3328 includes a 430 Ω reference resistor matched to PT100 use. |
| Adafruit SHT41 breakout (#5776) | 1 | Sensirion SHT41: ±1.8% RH, factory-calibrated. This build uses it for relative humidity only, since the MAX31865/PT100 is the primary temperature source. Its integrated heater keeps condensation from causing RH drift in cold, humid refrigerated spaces. I²C, 3.3 V. |
| Adafruit VEML7700 Lux Sensor (#4162) | 1 | 16-bit I²C ambient light sensor that watches interior cargo-bay light to detect an open door. It sits behind a polycarbonate lens on the cargo-bay-facing wall of the enclosure, so it reads near-zero lux while the container or reefer is sealed and a clear jump in lux once the cargo door opens and light gets into the compartment. A missing sensor returns -9999 in cargo_data.qo and cargo_log.qo, and the lux field is left out of cargo_alert.qo when it's unavailable. |
| Blues Mojo (bench bring-up only) | 0–1 | Coulomb counter inline on the VBAT rail for ground-truth energy measurements. It stays on the bench and isn't deployed to the field. See §9. |
| 3.7 V Li-Po battery, 2000 mAh (e.g., Adafruit #2011) | 1 | Portable power for multi-week shipments. The JST-PH 2-pin connector plugs straight into the Notecarrier CX. See §9 for runtime estimates and §11 for notes on UN 38.3 / IATA transport compliance. |
| ABS IP67 enclosure with mounting flanges | 1 | IP67-rated pallet-logger housing with three openings. An M12 hole on the pallet-facing side wall takes the PTFE vent plug (SHT41 air exchange only). A separately drilled and tapped 3/8-18 NPT hole on that same wall is where the PT100 probe enters. A 20 mm hole on the cargo-bay-facing wall holds the VEML7700 polycarbonate lens window. ABS is easy to machine. Cut the M12 hole with a standard step drill or hole saw, and drill the 3/8-18 NPT hole to tap-drill size (approx. 9.5 mm / 3/8″) before threading it with a 3/8-18 NPT tap. |
| Skylo NTN + cellular/WiFi antenna (included with NOTE-NBGLWX) | 1 | The NOTE-NBGLWX ships with a Skylo-certified multi-band flexible antenna that attaches to the Notecard's MAIN u.FL port and covers both cellular (LTE-M) and Skylo NTN (S-Band / L-Band). Use only the included antenna on the MAIN port. Skylo certifies the NOTE-NBGLWX only with the antenna that comes in the kit. Mount it inside the ABS enclosure facing the sky. |
| ePTFE breathable membrane vent plug, M12x1.5, metal, IP68/IP69K (Amphenol LTW VENT-BJ005, available at DigiKey) | 1 | Lets the SHT41 sample the surrounding cargo-bay air without breaking the enclosure's IP67 seal. It goes in the dedicated M12 vent hole on the pallet-facing side wall (hand-tight + one half-turn). The PT100 probe has its own separate 3/8-18 NPT tapped hole on the same wall. |
| PTFE thread sealant tape, ½ in width (e.g., Oatey 31403 or equivalent, available at any hardware store) | 1 roll | Seals the 3/8-18 NPT probe fitting in the tapped probe-entry hole on the pallet-facing enclosure wall. Wrap the probe's NPT threads 2–3 times before you thread it in. |
| Clear polycarbonate disc, 20 mm diameter × 3 mm thick, with silicone RTV sealant (McMaster-Carr 8560K171 or equivalent) | 1 | Optical window for the inward-facing VEML7700. Press it into the 20 mm lens hole on the cargo-bay-facing wall and seal the edge with clear silicone RTV. |
| STEMMA QT / Qwiic 4-pin JST-SH cable, 100 mm (e.g. Adafruit #4210) | 2 | You need two. One runs from the Notecarrier CX Qwiic port to any Qwiic port on the SHT41, and the second runs from the SHT41's pass-through Qwiic port to the VEML7700. |
All Blues hardware ships with an active SIM that includes 500 MB of data and 10 years of service, with no activation fees and no monthly commitment.
5. Wiring and Assembly
All host I/O connects to the Notecarrier CX's dual 16-pin header. Notecard for
Skylo seats in the carrier's M.2 slot, and the included Skylo-certified antenna
attaches to the Notecard's MAIN u.FL port and stays inside the ABS
enclosure.
MAX31865 (SPI, temperature). Connect it to the host's hardware SPI bus:
| MAX31865 pin | Host pin | Header label |
|---|---|---|
| VIN | 3V3 | +3V3 |
| GND | GND | GND |
| CLK | SCK | SCK |
| SDO (MISO) | MISO | MISO |
| SDI (MOSI) | MOSI | MOSI |
| CS | D10 | CS / D10 |
The specified Omega PR-21C probe has a 4-wire M12 A-coded output connector, so use a field-wireable or pre-made M12 A-coded female cable to break out the four RTD leads inside the enclosure. Set the Adafruit #3328 breakout's jumpers for 4-wire PT100: desolder the default 2/3-wire jumper bridge and close the 4-wire pads as shown in the Adafruit product guide. Connect the four probe leads to the MAX31865's F+, F−, RTD+, and RTD− terminals. Then thread the probe's 3/8-18 NPT fitting into the dedicated probe-entry hole on the pallet-facing side wall, so the 150 mm sensing tip reaches into the cargo-bay airstream. This hole is separate from the M12 PTFE vent hole. Drill it to the 3/8-18 NPT tap-drill size, thread it with a 3/8-18 NPT tap, put 2–3 wraps of PTFE tape on the probe threads, and tighten the probe hand-tight plus a quarter-turn.
SHT41 and VEML7700 (I²C, humidity and interior light). Both breakouts have STEMMA QT / Qwiic connectors, and you'll need two 100 mm STEMMA QT cables. Run the first from the Notecarrier CX's Qwiic port to any Qwiic port on the SHT41, then run the second from the SHT41's pass-through Qwiic port to the VEML7700. The Notecarrier CX has I²C pull-ups on board, so you don't need external resistors.
I²C address summary (the two sensors share one bus with no conflict, and the MAX31865 uses SPI, not I²C):
| Sensor | I²C address |
|---|---|
| SHT41 | 0x44 (fixed) |
| VEML7700 | 0x10 (fixed) |
VEML7700 interior light path (cargo-bay-facing). Drill a 20 mm through-hole in the cargo-bay-facing wall of the enclosure (not the lid). Press a 20 mm × 3 mm clear polycarbonate disc into the hole, seal the perimeter with clear silicone RTV, and let it cure. Mount the VEML7700 breakout directly behind the lens so the sensor die looks inward through the enclosure wall, toward the cargo space.
Once deployed, the cargo-bay-facing wall of the enclosure looks into the reefer
compartment or container. The sealed compartment keeps daylight out, so the
VEML7700 reads near-zero lux while the cargo door is closed. When the door
opens, light gets in and the sensor reads above the light_open_lux threshold,
which fires a light_exposure alert and a HANDLING state transition. That gives
you reliable cargo-door-open detection at every handling event on the trip.
SHT41 air path (sensing through vent membrane). Drill an M12 through-hole in the pallet-facing side panel for the PTFE vent plug. This hole is only for the vent plug, since the PT100 probe enters through its own separate 3/8-18 NPT tapped hole on the same panel. Thread in the Amphenol LTW VENT-BJ005 vent plug (hand-tight + one half-turn). Inside the enclosure, place the SHT41 breakout with its sensor opening within 10 mm of the vent plug's inner face so the airflow from the vent reaches the sensor die.
ATTN-to-D5 jumper (host sleep).
Fit a short jumper between the ATTN and D5 pins on the Notecarrier CX
header. Both are on the same 16-pin header (ATTN at position 3, D5 at
position 14). The Notecard pulls ATTN low while the host sleeps and raises it
when the next sample is due, and the rising edge on D5 wakes the host from
STOP2. Leave the Notecarrier CX EN pin unconnected. On the CX, that pin
enables the shared 3.3 V VIO rail (host, Qwiic, 3V3_OUT, and Notecard I/O),
so driving it from ATTN would brown out the whole board, along with the
Qwiic-powered SHT41 and VEML7700, instead of putting the host to sleep. Putting
a Host to Sleep Between Sensor
Readings
walks through the full pattern.
Additional wiring:
- +VBAT → Mojo
LOADoutput (bench only). For field deployment, plug the Li-Po straight into the Notecarrier CX's JST-PH LiPo jack.
Antenna placement (internal). Attach the included Skylo-certified antenna to
the Notecard's MAIN u.FL port. Mount it inside the ABS enclosure with its face
pointing up toward the lid, which gives Skylo NTN as much sky-facing aperture as
possible. Do not substitute a different antenna on the MAIN port. This
project leaves the GPS u.FL port unconnected.
No external accelerometer required. Notecard for Skylo has a built-in 3-axis accelerometer. The firmware turns on motion tracking with
card.motion.mode,start:true, andsensitivity:2. It doesn't set the sample rate or G-range, because the Notecard manages those internally. Thecard.motionresponse supplies per-bucket motion counts for shock detection and the current orientation (itsstatusfield) for tilt detection.
6. Notehub Setup
-
Create a project. Sign up at notehub.io and create a project. Copy the ProductUID, which looks like
com.your-company.your-name:cold-chain. -
Set the ProductUID in firmware. In
firmware/cargo_cold_chain_monitor/cargo_cold_chain_monitor_helpers.h, uncomment the// #define PRODUCT_UID "com.your-company.your-name:cold-chain"line and replace the placeholder with your value. -
Claim the Notecard. Power up the assembled unit. The Notecard associates itself with your Notehub project on its first successful radio session over any available RAT, whether that's cellular, Skylo NTN, or WiFi. Once that first session completes, the device shows up in your project's Devices tab. With good cellular or WiFi coverage, that usually takes a minute or two after power-on. First contact over Skylo NTN needs a clear, unobstructed view toward the equatorial sky. Acquiring that session can take several minutes, and it can't happen at all inside enclosed metal structures or below grade. The Notecard MUST sync over cellular or WiFi once before it can use NTN.
WiFi provisioning (optional). When WiFi credentials are present, Notecard for Skylo treats WiFi as its highest-priority radio, but the credentials are not provisioned automatically. To provision WiFi, send a
card.wifirequest to the Notecard from the in-browser Notecard terminal or the Notecard CLI:{ "req": "card.wifi", "ssid": "your-network-ssid", "password": "your-network-password" }Devices deployed without pre-provisioned WiFi credentials run on cellular and satellite only, which is the normal mode for road and ocean transit segments.
-
Create a Fleet per lane or customer. Fleets group devices for shared configuration. One fleet per customer or product type works well (e.g.,
insulin-2-8cvsfresh-produce). Smart Fleets can assign devices automatically based on a serial-number prefix, so each new logger picks up the right thresholds the first time it shows up. -
Set environment variables. In Notehub, go to Fleet → Environment (or Device → Environment for per-device overrides). Every variable is optional, and the table lists the firmware defaults.
How env-var delivery works.
env.getreads the Notecard's locally cached copy on every wake, so a value that's already been delivered takes effect on the very next sample cycle. New or changed values you set in the Notehub console only reach the device after the next inbound sync, and the default inbound cadence isINBOUND_INTERVAL_MIN = 720(12 hours). During commissioning, force a sync by clicking Sync on the device's detail page, or dropINBOUND_INTERVAL_MINto 60 for the bench session and put it back before field deployment.Variable Default Purpose temp_min_c2.0Lower temperature limit (°C). This is the standard pharma cold chain value, and fresh produce lanes may differ. temp_max_c8.0Upper temperature limit (°C). temp_highfires above it, andtemp_lowfires belowtemp_min_c.humidity_max_pct75.0Relative humidity ceiling (%). High RH in a refrigerated space points to condensation risk or a reefer malfunction. light_open_lux50.0Interior lux at or above which light_exposurefires and the state moves to HANDLING. A sealed reefer or container reads near 0 lux inside, so 50 lux reliably catches a door opening.shock_events5Accumulated motion-event count per sample interval at or above which shock_detectedfires. Lower values catch lighter impacts. Raise it to 10–15 for lanes where you expect road vibration.sample_interval_sec300Seconds between sample cycles (minimum 60). Dropping it to 60 for bench testing is fine. summary_interval_min60Base minutes between cargo_data.qosummary Notes, stretched bydwell_batch_factorduring confirmed DWELL. This sets how often summaries are generated. The outbound sync interval is the compiledOUTBOUND_INTERVAL_MINconstant (60 minutes by default).transit_motion_min3Motion events per sample interval at or above which the sample counts as "moving." Raise it for noisy lanes (vibration-heavy roads). dwell_confirm_samples3Consecutive low-motion samples before the state moves to DWELL (~15 minutes at the default cadence). transit_confirm_samples2Consecutive high-motion samples before the state moves to IN_TRANSIT (~10 minutes at the default cadence). dwell_batch_factor4Multiplier applied to both summary_interval_minand the hub.set outbound cadence during confirmed DWELL. The default of 4 gives you 4-hour summary intervals and 4-hour outbound sync windows in a warehouse (9 sessions per 36-hour dwell instead of 36). Alert and state-change Notes (sync:true) still trigger an immediate session either way. Set it to 1 to turn off dwell batching entirely. -
Configure routes. Add one route for
cargo_alert.qoandcargo_state.qo(to a TMS webhook or on-call endpoint), one forcargo_data.qo(to a cold-chain analytics platform), and one forcargo_log.qo(to a compliance data store or audit system). Because the four Notefiles are separate at the source, you set routing policy once, and each downstream system gets only the event streams it needs.
What to expect in Notehub
The Events tab starts filling in shortly after first power-on. How soon depends on which RATs are available (see step 3 above).
-
_session.qo: automatic Notecard housekeeping on each radio session. It makes a good first-light sanity check. -
cargo_data.qo: one per effective summary interval (60 minutes in transit/handling, up to 240 minutes during DWELL at default settings), starting once the first full interval has passed since anchor time. Body:{ "_time": 1748000000, "temp_mean_c": 4.3, "temp_min_c": 3.8, "temp_max_c": 4.9, "rh_mean_pct": 62.1, "rh_min_pct": 60.4, "rh_max_pct": 63.7, "lux_max": 0.3, "motion_total": 2, "motion_valid": 1, "samples": 12 }A field reading
-9999means there was no valid sensor data for that metric in the window. Treat it as a sensor fault, not a near-zero measurement. -
cargo_log.qo: one compact entry per sample cycle, batched with the regular outbound sync. Body:{ "_time": 1748000300, "seq": 42, "temp_c": 4.3, "rh_pct": 62.5, "lux": 0.2, "motion_valid": 1, "state": 1, "boot_seg": 1, "chain_crc": 3748291045 }_timeis always present. Entries logged before the Notecard had a valid epoch carry_time = 0as a pre-sync sentinel, so use Notehub's event receive-time as the approximation for those records.motion_validis1whencard.motionreturned valid data and0when the accelerometer interface was unavailable. Notehub leaves zero-valued fields out of templated Notes, so a pallet that didn't move (like this one) showsmotion_validwith nomotionfield. An entry with neither field means the motion data was missing, not that nothing moved.statemaps to0=unknown,1=dwell,2=in_transit, and3=handling.boot_seggoes up on every cold boot, and all entries with the sameboot_segform one continuous chain segment. Within a segment, a gap inseqpoints to a missed entry. Achain_crcmismatch (when replaying the chain within oneboot_segfrom seq=1, seed=0) points to a modified or inserted record. When a newboot_segvalue appears, seq resets to 1 and a fresh chain starts from seed=0. -
cargo_state.qo: sent right away on every shipment-state transition. Body:{ "_time": 1748001200, "state_from": "dwell", "state_to": "in_transit" } -
cargo_alert.qo: sent right away, and only when a threshold trips. Thealertfield is one oftemp_low,temp_high,humidity_high,shock_detected,light_exposure, ortilt_detected. Standard alert:{ "_time": 1748001500, "alert": "temp_high", "temp_c": 9.1, "rh_pct": 64.2, "lux": 0.1, "motion": 2 }Tilt alerts add
orientation_fromandorientation_to. The firmware leaves out the field for any sensor that was unavailable when the alert fired, and because the Notefile is templated, Notehub also drops any field whose value is exactly0. So in an alert, a missingluxormotionfield means either a sensor fault or a zero reading.-9999only shows up incargo_data.qoandcargo_log.qo.
7. Firmware Design
The main sketch is
firmware/cargo_cold_chain_monitor/cargo_cold_chain_monitor.ino
(setup(), loop()). Constants, ColdChainState, and prototypes live in
cargo_cold_chain_monitor_helpers.h, and the sensor, Notecard, and
state-machine helpers are in cargo_cold_chain_monitor_helpers.cpp. The
host-sleep helpers (the card.attn request, STOP2 entry, and ATTN wake) are in
cx_sleep.h.
7.1 Installing and flashing
Dependencies:
- Arduino core for STM32:
stm32duino/Arduino_Core_STM32. Install it from the Arduino Boards Manager. Add the index URLhttps://github.com/stm32duino/BoardManagerFiles/raw/main/package_stmicroelectronics_index.jsonunder File → Preferences → Additional Boards Manager URLs, then search for STM32 MCU based boards. Blues Wireless Notecardlibrary (note-arduino). Install it witharduino-cli lib install "Blues Wireless Notecard"or search for "Blues Wireless Notecard" in the Arduino IDE Library Manager.Adafruit MAX31865 library: install it from the Library Manager (search "Adafruit MAX31865").Adafruit SHT4x Library: install it from the Library Manager (search "Adafruit SHT4x").Adafruit VEML7700 Library: install it from the Library Manager (search "Adafruit VEML7700").STM32duino Low Power(and its dependencySTM32duino RTC):stm32duino/STM32LowPower. Install it from the Library Manager. It provides the STOP2 entry and ATTN-pin wake thatcx_sleep.huses.
Build settings: The Notecarrier CX's host is the same STM32L433 as the Blues
Cygnet, so select Blues boards → Cygnet (pnum=CYGNET) and set USB
support (if available) to None (usb=none). If the USB CDC stack is
enabled and no USB host is attached (battery power, or the Notecarrier CX USB-C
switch in NC), the STM32's USB wakeup interrupt fires continuously and the
host never stays in STOP2. Debug output goes to the LPUART on the Notecarrier CX
debug jack instead, which an ST-LINK V3 exposes as a virtual COM port (see
Serial Logging With STLINK).
Flashing with arduino-cli: run these from the repo root:
# Compile — usb=none disables the USB CDC stack, which must be off for the
# host to sleep.
arduino-cli compile -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none \
firmware/cargo_cold_chain_monitor/
# Upload (with the Notecarrier CX USB-C DIP switch in HST, the host's DFU
# bootloader is reachable over USB-C: hold BOOT, tap RST)
arduino-cli upload -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none \
-p /dev/cu.usbmodem* firmware/cargo_cold_chain_monitor/
firmware/cargo_cold_chain_monitor/sketch.yamlsets the same board options, so runningarduino-cli compilein that directory without-bpicks them up too. Replace/dev/cu.usbmodem*with your actual port (COMxon Windows,/dev/ttyACM*on Linux). You can also upload over SWD with an ST-LINK on the debug jack.
Open a serial monitor on the ST-LINK virtual COM port at 115200 baud to
watch the [cargo] log lines. USB CDC is disabled, so nothing shows up on the
Notecarrier CX's own USB-C port. The first cold boot prints Notecard
configuration messages, followed by one [cargo] T=X.XX C (PT100) and one
[cargo] RH=XX.X % per wake. After the first summary interval you'll see
[cargo] summary sent — samples=N.
7.2 Modules
| Responsibility | Where in code |
|---|---|
Notecard configuration (hub.set, card.transport, motion mode) with warm-boot retry | notecardConfigure() + flags in ColdChainState |
| Compact template definition for all four Notefiles | defineTemplates() + templates_registered flag |
| Environment-variable fetch, clamp, and cadence-change window reset | fetchEnvOverrides(), envFloat() helper |
| PT100/MAX31865 temperature reads (SPI) | readSensors() |
| SHT41 relative humidity reads (I²C, humidity channel only) | readSensors() |
| VEML7700 interior cargo-bay light reads | readSensors() |
| Notecard built-in accelerometer motion count + orientation | readMotionCount() |
| Shipment-state detection (DWELL / IN_TRANSIT / HANDLING) | detectShipmentState() |
| State-transition immediate-sync Note | sendStateChange() |
| Tamper-evident per-sample log entry (seq, chain_crc) | sendLogEntry() |
| Alert cooldown logic | alertCooldownOk() |
| Threshold evaluation and alert dispatch | evaluateAlerts(), sendAlert(), sendTiltAlert() |
| Rolling sample accumulation | accumulateSample() |
| Adaptive summary interval (dwell batching), snapshot/retry/discard | snapshotSummary(), sendPendingSummary(), resetAccumulators() |
| Application state (held in RAM across sleep cycles) | ColdChainState struct |
Host sleep (STOP2 until the Notecard raises ATTN) | cx_sleep.h: cxSleepBegin(), cxSleepUntilAttn() |
| Epoch time for cooldowns and timestamps | currentEpoch() |
7.3 Sensor reading strategy
- MAX31865 / PT100.
rtd.begin(MAX31865_4WIRE)re-initializes the MAX31865 in 4-wire mode on every wake, to match the specified Omega PR-21C probe, so a transient SPI or sensor fault never carries over from one sample to the next. In 4-wire mode the chip drives force current through the outer pair of leads and measures voltage across the inner pair, which cancels out lead-resistance error. That matters once the probe cable runs past 0.5 m.rtd.temperature(MAX31865_RNOMINAL, MAX31865_RREF)reads the RTD resistance and applies the Callendar–Van Dusen polynomial.rtd.readFault()checks for open-circuit (RTDINLOW), short-circuit (HIGHTHRESH), and reference-voltage faults. Any non-zero fault setstemp_c = INVALID_Fand clears the fault register so the next wake starts with a fresh read. Valid temperatures are range-checked (−200 °C to +200 °C) before the firmware accepts them. The 430 Ω reference resistor on the Adafruit #3328 breakout matches the PT100 nominal range. If you swap in a PT1000 probe, update theMAX31865_RREFandMAX31865_RNOMINALconstants inhelpers.h. - SHT41 (humidity only).
setPrecision(SHT4X_HIGH_PRECISION)selects the ±1.8% RH mode.getEvent()returns both a temperature and a humidity struct, but the firmware only uses the humidity channel, since the PT100/MAX31865 is the authoritative temperature source. NaN guards run before accumulation. - VEML7700. Gain
VEML7700_GAIN_2and integration timeVEML7700_IT_100MSfavor sensitivity, which suits the near-zero lux levels you'd expect inside a sealed reefer or container. A missing VEML7700 returnsINVALID_F(not 0.0), so you can tell a sensor fault from real darkness. When lux isINVALID_F, both thelight_exposurealert and HANDLING state detection are skipped. - Accelerometer. The firmware calls
card.motionwithminutes: gSampleSec / 60to get a non-overlapping window that covers exactly the elapsed sample interval. Env-var processing always clampssample_interval_secto a whole number of minutes, so the division comes out exact. The response'smovementsstring holds one base-36 character per bucket (0–9, thenA–Zfor 10–35, and*for more than 35), and the firmware adds them up, counting*as 36. The current orientation comes from the response'sstatusfield. A value ofunavailable(accelerometer off) is ignored, so it never becomes the tilt baseline.
7.4 Event payload design
cargo_data.qo: adaptive cadence, compact-templated on port 50. Compact
templates (all four are registered in defineTemplates(), and the
cargo_log.qo one is shown at
§7.7) shrink each message from
~200 bytes of free JSON to ~50 bytes on the wire, which noticeably cuts
satellite session overhead. During
confirmed DWELL the effective interval is
summary_interval_min × dwell_batch_factor (4 hours by default). In IN_TRANSIT
and HANDLING, the base summary_interval_min (60 minutes by default) applies.
The compact body keeps the _time field, so each record carries its own audit
timestamp that doesn't depend on Notehub's receive-time metadata.
cargo_log.qo: one entry per sample cycle, compact-templated on Notehub
port 51 (defined at §7.7).
Entries wait for the regular outbound window, so they never need an extra
satellite session per sample. Each entry carries seq (monotonic counter,
incremented before every note.add), boot_seg (cold-boot counter, incremented
and persisted to chain_boot.dbx on every cold boot), and chain_crc (rolling
hash computed per §7.8
over previous hash + seq + boot_seg + all sensor fields). Every entry also has a
_time field. It holds the real sample epoch when valid time is available, or
0 as a documented pre-sync sentinel when the Notecard hasn't gotten time from
Notehub yet. Writing 0 instead of leaving the field out keeps the
compact-template body consistent with the registered schema. Downstream
consumers should treat _time == 0 as pre-sync and use Notehub's receive-time
as the best available approximation for those entries.
The motion_valid field (1 or 0) separates a real zero-motion reading from
a cycle where card.motion was unavailable. Both cases store motion = 0, but
motion_valid = 0 flags missing data rather than confirmed stillness. The
chain hash uses the stored motion value (0 when card.motion is
unavailable), which keeps downstream replay from the logged fields simple. A
downstream verifier first groups entries by boot_seg, then replays the chain
within each group using the algorithm at
§7.8:
crc[n] = hash(crc[n-1], seq[n], boot_seg[n], temp[n], rh[n], lux[n], motion[n], state[n]),
starting from crc[0]=0. A seq gap within a group means a missed transmission,
and a chain mismatch means a modified or inserted record. A new boot_seg
value marks a cold-boot boundary and starts a fresh chain from seed=0.
cargo_state.qo: sent with sync:true on any DWELL / IN_TRANSIT /
HANDLING state transition, compact-templated on port 53. It has two fields,
state_from and state_to (string names, up to 10 characters), plus _time.
The firmware sets _time to the transition epoch when it has one, and
otherwise the Notecard fills in the Note's creation time. Together they give
you a near-real-time chain-of-custody record of when each handling and transit
event started and ended.
cargo_alert.qo: sent with sync:true when a threshold trips,
compact-templated on port 52. The template reserves 14 characters for alert
and 15 each for orientation_from and orientation_to (the longest
orientation is landscape-right), and its _time field gets the Note's
creation time from the Notecard.
Both Notefiles carry a port and the compact format, so they sync over
NTN as
well as cellular and WiFi. Without those templates, the Notecard would reject
note.add on them while it's connected over satellite. Because they use
sync:true, each one asks for a session right away, satellite included. The
alert template body is about 64 bytes, well under Skylo's 256-byte packet
limit.
7.5 Low-power and satellite strategy
setup() runs once at power-up, and each pass through loop() is one sample
cycle. After each cycle the host calls cxSleepUntilAttn() (in cx_sleep.h).
That function sends a
card.attn sleep
request for sample_interval_sec seconds, waits for the Notecard to pull
ATTN low, and puts the host into STM32 STOP2, where it draws a few microamps
with its RAM intact. When the interval is up, the Notecard raises ATTN. The
rising edge on D5 wakes the host, and execution picks up on the next line of
loop(). Since the host resumes in place instead of cold-booting, the whole
ColdChainState struct just stays in RAM across planned sleep/wake cycles.
That covers seq, chain_crc, boot_seg, last_outbound_min, the
shipment-state fields, alert cooldowns, and the summary window accumulators,
and none of it gets serialized to the Notecard. If the sleep request fails or
ATTN never goes low (a missing jumper), loop() waits out the interval awake
and tries again on the next cycle, so the sample cadence holds either way.
Cold boot behavior. A battery disconnect, brown-out, or deliberate reset
clears RAM and re-runs setup(). gState starts out zeroed, and
loadOrIncrementBootSeg() reads the boot_seg counter and the tilt baseline
orientation from chain_boot.dbx (a Notecard-local Notefile that survives
host power loss), increments the counter, and writes both back. Restoring
baseline_orientation from chain_boot.dbx means tilt detection after an
uncontrolled cold boot keeps comparing against the orientation captured at
logger activation, not the post-boot orientation. Otherwise a power loss in
transit could quietly reset the baseline and hide a real tilt event. The first
time the baseline is set (true first activation), persistBaselineOrientation()
saves it to chain_boot.dbx, and every cold boot after that reads it back
automatically. seq and chain_crc reset to 0, which starts a new independent
chain segment. In-progress summary accumulators and cooldown timestamps from
the interrupted cycle are lost. The remote log shows a new boot_seg value at
the first entry after the reboot, so the segment boundary is easy to spot.
Downstream verifiers should treat each boot_seg as an independent chain.
On the satellite side, the compact template format on all four Notefiles
keeps the byte count per Note small, and the 12-hour inbound
interval (INBOUND_INTERVAL_MIN = 720) limits what NTN inbound polls cost (~50
bytes per poll). Dwell-period batching (4× by default) goes further. It
stretches both the summary generation interval and the Notecard outbound
sync cadence through applyDynamicOutbound(), which directly cuts the number
of outbound NTN sessions during long warehouse stays. Alert and state-change
Notes (sync:true) always trigger an immediate session, whatever the configured
outbound cadence.
7.6 Retry and error handling
hub.set,card.transport,card.motion.mode, and all fournote.templateregistrations run insetup()at power-up. Each one sets a flag inColdChainStatewhen it succeeds and gets retried on later wakes until it's confirmed. Every reset zeroesColdChainState, so a reflash always reapplies all of them, and a newPRODUCT_UIDor template schema takes effect immediately (SCHEMA_VERSION = 6, which added thecargo_alert.qoandcargo_state.qotemplates).- Alert cooldown timestamps only advance once the Notecard confirms the
note.add. A transient failure leaves the cooldown state alone, so the next wake retries. seqandchain_crcadvance before thenote.addcall insendLogEntry(), so the chain reflects the physical sequence of events. A failed transmission burns its sequence number, and the gap it leaves in the remote log is itself evidence of a dropped record.- State-change retry: if a
cargo_state.qonote.addfails, the firmware stores it in theColdChainState.pending_state_change/pending_state_*fields and retries it on every wake before new state detection runs, so chain-of-custody records arrive in sequence. Only one transition is held, though. If the retry fails and a new transition happens on the same wake, the new one overwrites the pending record, and the older transition is lost. - Summary retry: a failed
sendPendingSummary()leavespending_epochset, and the frozen snapshot gets retried on every wake after that. If the Notecard stays unreachable for one more full summary window, the firmware discards the stale snapshot (logging a warning) and replaces it with the newly completed window.
7.7 Key code snippet 1: compact log template
defineTemplates() reuses its req and body variables for each template.
NOTE_LOG is cargo_log.qo, and seq resets with each new boot_seg.
req = notecard.newRequest("note.template");
JAddStringToObject(req, "file", NOTE_LOG);
JAddNumberToObject(req, "port", 51);
JAddStringToObject(req, "format", "compact");
body = JAddObjectToObject(req, "body");
JAddNumberToObject(body, "_time", TUINT32); // sample epoch; 0 = pre-sync sentinel
JAddNumberToObject(body, "seq", TUINT32); // monotonic sequence counter
JAddNumberToObject(body, "temp_c", TFLOAT32); // instantaneous PT100 temperature
JAddNumberToObject(body, "rh_pct", TFLOAT32); // instantaneous relative humidity
JAddNumberToObject(body, "lux", TFLOAT32); // instantaneous interior lux
JAddNumberToObject(body, "motion", TUINT32); // motion events this interval (0 when motion_valid=0)
JAddNumberToObject(body, "motion_valid", TUINT16); // 1 = card.motion available; 0 = data unavailable
JAddNumberToObject(body, "state", TUINT16); // shipment state (SHIP_STATE_*)
JAddNumberToObject(body, "boot_seg", TUINT16); // boot-segment counter
JAddNumberToObject(body, "chain_crc", TUINT32); // rolling integrity hash
if (!ncSend(req)) {
debugSerial.println("[cargo] cargo_log.qo template failed — will retry on next wake");
allOk = false;
}7.8 Key code snippet 2: integrity chain hash update
boot_seg is mixed into the hash, so chains from different boot segments
differ even when seq and the sensor values happen to match.
// chainUpdate: djb2-rotate-based hash chain. Each call mixes the previous
// hash with the current sample's raw field values to produce a new hash.
// Float fields are mixed as their IEEE-754 bit patterns so INVALID_F is
// hashed consistently regardless of sensor availability. Downstream
// consumers replay this chain from seq=1 (seed=0) to detect any insertion,
// deletion, or modification in the remote log.
static uint32_t chainUpdate(uint32_t prev, uint32_t seq, uint16_t boot_seg,
float temp_c, float rh_pct, float lux,
uint32_t motion, uint8_t state) {
#define ROTMIX(h, v) ((h) = (((h) << 5) | ((h) >> 27)) ^ (v))
uint32_t h = prev ^ 0x5A827999UL;
ROTMIX(h, seq);
ROTMIX(h, (uint32_t)boot_seg);
uint32_t bits;
memcpy(&bits, &temp_c, sizeof(bits)); ROTMIX(h, bits);
memcpy(&bits, &rh_pct, sizeof(bits)); ROTMIX(h, bits);
memcpy(&bits, &lux, sizeof(bits)); ROTMIX(h, bits);
ROTMIX(h, motion);
ROTMIX(h, (uint32_t)state);
#undef ROTMIX
return h;
}7.9 Key code snippet 3: shipment-state detection and adaptive batching
In loop(), right after the sensor reads, the firmware records a new
transition as pending before it tries the send, so a failed note.add gets
retried on the next wake. Further down, the summary window stretches during
confirmed dwell, and effectiveSummaryMin feeds the intervalElapsed check
that decides when to freeze a summary.
uint8_t prevShipState = gState.shipment_state;
bool stateChanged = detectShipmentState(motion, motionOk, lux, now);
if (stateChanged) {
gState.pending_state_change = true;
gState.pending_state_from = prevShipState;
gState.pending_state_to = gState.shipment_state;
gState.pending_state_epoch = now;
if (sendStateChange(prevShipState, gState.shipment_state, now)) {
gState.pending_state_change = false;
}
}
// ...
uint32_t effectiveSummaryMin = gSummaryMin;
if (gState.shipment_state == SHIP_STATE_DWELL) {
uint32_t extended = gSummaryMin * gDwellBatchFactor;
effectiveSummaryMin = (extended > 1440U) ? 1440U : extended;
}7.10 Key code snippet 4: sleeping until the Notecard raises ATTN
cxSleepUntilAttn() asks the Notecard to hold ATTN low for exactly
gSampleSec seconds, confirms the pin has gone low, and puts the STM32 into
STOP2. The host never powers off, so the card.attn call is a request whose
result gets checked, not a fire-and-forget command. The Notecard's ATTN pin
brings the host back on schedule, and loop() moves on to the next sample with
gState untouched.
setup() arms the wake once, and the sleep call is the last thing in loop().
// ── Arm the ATTN wake: the Notecard raises ATTN (D5) to end each sleep ────
cxSleepBegin();
// ...
// ── Sleep in STOP2 until the Notecard raises ATTN gSampleSec from now ────
if (!cxSleepUntilAttn(notecard, gSampleSec, NULL, &debugSerial)) {
// The Notecard didn't take the sleep request, or ATTN never went low
// (check the ATTN -> D5 jumper). Keep the sample cadence and try again.
debugSerial.println("[cargo] ATTN sleep failed — waiting out the interval awake");
delay(gSampleSec * 1000UL);
}8. Data Flow
Every five minutes the firmware wakes up, reads all the sensors and the accelerometer, and updates the shipment-state model. Then it checks the threshold rules, writes a per-sample log entry, and queues the readings for the next summary, sends an alert Note, or does both.
Shipment-state model. The firmware tracks three confirmed states (DWELL,
IN_TRANSIT, HANDLING) based on consecutive motion counts and interior lux.
Entering DWELL stretches the summary interval by dwell_batch_factor, while
IN_TRANSIT and HANDLING use the base summary_interval_min. Any state
transition sends a cargo_state.qo Note right away with sync:true. In
practice:
- A pallet sitting at a DC for 36 hours generates a summary Note every 4 hours (by default) and syncs outbound only every 4 hours. That's 9 summaries and 9 outbound sessions instead of 36 each, which saves NTN data budget during the long dwell. Alert and state-change Notes (sync:true) can still trigger an immediate session at any point.
- After
transit_confirm_samplesconsecutive high-motion sample intervals (two by default, or about 10 minutes at the default 5-minute cadence), the state moves to IN_TRANSIT and a state-change Note goes out right away, so the remote system knows the shipment is moving. - When the cargo door opens, the state moves to HANDLING and a
light_exposurealert fires at the same time.
Collected. Every 5-minute cycle: temperature (°C, from PT100/MAX31865), relative humidity (%, from SHT41), interior lux (from VEML7700), the accumulated motion-event count, the current orientation string, and the shipment state.
Transmitted.
cargo_log.qo: one compact entry per sample cycle, batched with the regular outbound sync instead of sent right away. Each entry holds_time(real epoch or0for pre-sync),seq,temp_c,rh_pct,lux,motion,motion_valid,state,boot_seg, andchain_crc.cargo_data.qo: one compact summary per effective summary interval (hourly in transit, batched during dwell). It has mean/min/max for temperature and humidity, peak lux, total motion events, themotion_validflag, and the sample count.cargo_state.qo: sent on every DWELL / IN_TRANSIT / HANDLING state transition, withsync:true.cargo_alert.qo: sent only when a threshold trips, withsync:true.
Routed. Notehub sends each Notefile to its configured destination. The four
files are separate at the source, so you set routing policy once.
cargo_log.qo and cargo_data.qo go to a compliance data store and analytics
platform, and cargo_alert.qo and cargo_state.qo go to real-time endpoints
(TMS, on-call webhook, SMS gateway).
Alerts trigger on:
temp_low: temperature belowtemp_min_c(default 2.0 °C), measured by the PT100.temp_high: temperature abovetemp_max_c(default 8.0 °C), measured by the PT100.humidity_high: relative humidity abovehumidity_max_pct(default 75%).shock_detected: accumulated motion-event count in the sample window ≥shock_events(default 5). This is a heuristic event-count threshold, not calibrated peak-G.light_exposure: interior lux ≥light_open_lux(default 50 lux). It fires when the cargo door or container lid opens and light gets into the cargo space, and it also triggers the HANDLING state transition.tilt_detected: the current orientation differs from the baseline captured at logger activation, which catches a pallet tipped on its side. It addsorientation_fromandorientation_tofields to the standard alert body.
Each alert type has its own 30-minute cooldown (ALERT_COOLDOWN_SEC). A
sustained temperature excursion over a 6-hour transit produces at most 12
alerts per type. That's enough to document the event without flooding an
on-call queue.
9. Validation and Testing
Expected steady-state cadence. In normal refrigerated transit (IN_TRANSIT
state), a healthy unit produces one cargo_data.qo summary per hour, one
cargo_log.qo entry per 5-minute sample cycle (batched into hourly outbound
syncs), and zero cargo_alert.qo events. During confirmed DWELL, summaries slow
to one every 4 hours (by default). The first summary shows up about
summary_interval_min minutes after the unit first gets a valid epoch from
card.time.
Using Mojo for bench power validation. For bench bring-up, connect the Mojo inline between the Li-Po battery and the Notecarrier CX +VBAT rail.
The figures in this table come from two different sources, and the Source column says which is which:
| Phase | Radio | Source | Expected |
|---|---|---|---|
| Host + sensors active (~5–10 seconds per wake) | n/a | Full-system estimate | 15–30 mA |
| Host in STOP2 + Notecard idle (between radio sessions) | n/a | NOTE-NBGLWX datasheet + STM32L433 STOP2 figure | ~10 µA (Notecard ~8 µA + host ~1–2 µA); < 0.1 mA total system |
| Hourly outbound sync, small Note batch | LTE-M / 2G | Full-system estimate | 150–300 mA average, 15–45 s burst |
| Hourly outbound sync, small Note batch | WiFi | Full-system estimate | 80–150 mA average, 5–20 s burst |
| Satellite session (NTN) | Skylo NTN | Full-system estimate | 250–450 mA during acquisition + transfer. Acquisition takes 1–5 min with clear sky, and NTN isn't available inside enclosed metal or below grade |
Notecard datasheet figures: the NOTE-NBGLWX datasheet publishes the authoritative idle and peak-session current for the NOTE-NBGLWX. Skylo NTN has a noticeably different power profile from standard cellular Notecards, so check the datasheet for the authoritative per-mode numbers. The rows labeled "full-system estimate" add STM32L433 host and sensor draws based on typical STM32L4 and sensor datasheet values. Treat them as order-of-magnitude guidance, not published specifications.
Battery-life estimates (full-system, cellular/WiFi-dominant lanes). At the default 5-min sample / 1-hr cellular sync cadence on a 2 000 mAh battery:
- 12 host wakes/hour × ~7 seconds × 20 mA ≈ 0.47 mAh/hour
- 1 hourly LTE-M sync × 30 seconds avg × 250 mA ≈ 2.1 mAh/hour
- Notecard idle overhead ≈ 0.02 mAh/hour
- Total: ~2.6–3.5 mAh/hour consumed → ~62–84 mAh consumed per day → approximately 24–32 days of operation
Battery-life estimates (satellite-heavy lanes). Each Skylo NTN session uses a lot more energy than a cellular session. Here's a conservative estimate for a session including acquisition (~150 seconds at ~350 mA average):
- Per NTN sync session: ~14–15 mAh
- 50% of hourly syncs over NTN (24 syncs/day, 12 over NTN): ~8–9 mAh/hour average radio → ~9–10 days on 2 000 mAh
- All syncs over NTN (24 syncs/day over NTN): ~15–16 mAh/hour → ~5–6 days
Improving satellite endurance:
dwell_batch_factorautomatically stretches the outbound sync cadence during confirmed warehouse dwell, with no reflash. On a typical pharma shipment (3–5 days in transit, 24–36 hours in warehouses), dwell batching can cut total NTN sessions by 50–70%, which buys a lot of battery life. For routes with long dwell periods, setdwell_batch_factorto 6 or 8 instead of the default 4.
On satellite-reliant lanes, dwell_batch_factor is the main lever. It
stretches the outbound sync cadence during warehouse dwell without a reflash,
which directly cuts the number of NTN sessions during the longest stretches of
a typical shipment. Raising OUTBOUND_INTERVAL_MIN (a compile-time constant,
so it needs a reflash) gives a blanket reduction no matter the state. The
cargo_log.qo entries add a modest amount of data (~30 bytes/entry × 12
entries/hour = ~360 bytes/hour in transit, batched into fewer, larger sessions
during dwell). They go out in the same outbound sessions as the summary Notes,
not in sessions of their own.
Healthy Mojo trace pattern:
- Flat near-zero baseline, brief blips at the sample interval (host active), one radio burst per outbound sync.
- Failure mode A: a flat, continuous baseline at 15–30 mA means the host
never sleeps, because it isn't entering STOP2. Check the
ATTN→D5jumper (without it, the firmware logs[cargo] ATTN sleep failedand waits out each interval awake). Also confirm the firmware was built with USB support set to None (usb=none), since with the USB CDC stack enabled the host leaves STOP2 within a millisecond. - Failure mode B: hourly bursts that run 5–10 minutes mean the radio is struggling and retrying on a weak signal.
Functional smoke tests:
Env-var prerequisite. After you change an environment variable in the Notehub console, push it to the device by clicking Sync on the device's detail page, or wait for the next inbound sync (up to 12 hours at the default cadence).
- Temperature alert: Set
temp_max_cto10.0and sync. (It has to stay abovetemp_min_c, or the firmware ignores the pair.) On the next wake (withinsample_interval_secseconds), the room-temperature bench is over the threshold and the device emitscargo_alert.qowithalert=temp_high. Put the value back when you're done. - Light-exposure / door-open alert: Set
light_open_luxto1.0, sync, and shine a flashlight through the cargo-bay-facing lens window. On the next wake you should seecargo_alert.qowithalert=light_exposureandcargo_state.qowithstate_to=handling. - Shock alert: Set
shock_eventsto2, sync, and tap the assembly firmly. Ashock_detectedalert should arrive on the next wake. - Tilt alert: Let the baseline orientation get set (you'll see
[cargo] orientation baseline set: face-upon the serial monitor), then rotate the assembly. Confirm atilt_detectedalert withorientation_fromandorientation_tofields. - Dwell → in-transit transition: Leave the unit still for ≥ 3 sample cycles
(the
dwell_confirm_samplesdefault) and confirmcargo_state.qowithstate_to=dwell. Then move the unit briskly for 2 sample cycles and confirmcargo_state.qowithstate_to=in_transit. - Chain integrity: Pull a run of
cargo_log.qoentries from Notehub and group them byboot_seg. Within each group, replay the chain hash from seq=1, seed=0 against the recorded field values, using the same algorithm (chainUpdatewithboot_segas a parameter). The finalchain_crcyou compute for each group should match the last entry. A newboot_segvalue resets the replay to seed=0.
10. Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
Serial monitor shows [cargo] MAX31865 fault 0x... on cold boot | A transient MAX31865 fault at startup that doesn't persist. | The firmware re-initializes the MAX31865 on every wake, so the fault should clear by the next sample cycle. If it doesn't, check the SPI pin connections and probe wiring. |
| Device does not appear in Notehub after 5 minutes | No radio is available, because of a dead zone or an antenna issue. | Check that the antenna is attached to the Notecard's MAIN u.FL port (not GPS). If cellular is available, try a manual sync with the hub.sync API from the terminal. Skylo NTN needs a clear sky, so move the unit outside. |
Build output shows the PRODUCT_UID is not defined pragma message | PRODUCT_UID isn't set. The firmware falls back to an empty ProductUID, so hub.set goes out without a product and the device won't land in your Notehub project unless its Notecard kept a ProductUID from an earlier setup. | In helpers.h, uncomment the #define PRODUCT_UID line, replace the placeholder with your ProductUID, and rebuild. The message goes away once it's set. Flash as described in §7.1 and confirm the device shows up in your Notehub project. |
cargo_log.qo entries show _time=0 forever | The device hasn't gotten a valid epoch from Notehub yet. | This is the documented "pre-sync sentinel" behavior. Notecard gets time on its first completed sync with Notehub, and entries logged after that carry the real epoch. To get there sooner, force a sync by clicking Sync on the device detail page. |
No cargo_alert.qo entries despite obvious threshold breach | That alert type already fired within the last 30 minutes, so its cooldown is active. | This is by design, since cooldowns prevent repeated alerts during sustained excursions. Check the cargo_log.qo per-sample entries and cargo_data.qo summaries instead, which show the full condition history. |
| Battery drains in < 7 days (cellular lanes) | The host isn't sleeping between samples (a missing ATTN→D5 jumper, or firmware built with USB support enabled), continuous motion is preventing DWELL batching, or outbound sync is too frequent. | Use a Mojo trace to confirm the host enters STOP2 between samples. A flat 15–30 mA baseline means it doesn't, so check the ATTN→D5 jumper (§5) and the usb=none build option (§7.1). Then check the hub.set config in the firmware logs. If the device sits somewhere with heavy vibration, raise the transit_motion_min threshold (env-var) to cut down on false motion counts. For satellite-heavy lanes, see the §9 battery estimates. |
Serial monitor never shows a [cargo] T= temperature line | MAX31865 SPI communication failed, most likely from a pin mismatch. | Check the SPI pinout: CS=D10, CLK=SCK, SDO=MISO, SDI=MOSI (the CX's dedicated SPI header pins, not D11–D13). Check the 3.3V supply to the MAX31865's VIN, and test SPI communication with a simple Arduino SPI.transfer() sketch. |
cargo_state.qo transitions never fire, stays in "unknown" | card.motion isn't returning motion data. | Check the firmware logs for [cargo] card.motion unavailable, and confirm card.motion.mode was applied (see the [cargo] boot logs). If the problem continues, the Notecard may need a power cycle or a firmware update. |
11. Limitations and Next Steps
The logger sticks to a specific scope. It provides independent, pallet-level evidence with a tamper-evident chain, limited to the radios and physics you actually have to work with in a shipping container. Calibrated peak-G shock recording, cryptographic tamper proof, GPS, and lithium transport paperwork are all real production concerns, and the sections below call each of them out. They build on this design instead of replacing it.
Remaining Design Boundaries
The logger stops short in a few places on purpose. At each of them, a production deployment will want to add something on top of the basic pallet-level logger, whether that's a radio, a calibrated sensor, stronger cryptographic evidence, or compliance paperwork.
Skylo satellite is opportunistic, not available inside enclosed metal structures. Skylo uses geostationary (GEO) satellites, so the antenna needs a clear view of the southern sky (in the northern hemisphere). Notes queue in flash and flush over cellular or WiFi once coverage comes back, which turns enclosed-transit segments into store-and-forward gaps, not data loss. On routes where enclosed transit outlasts the available battery reserve, a Starnote for Iridium can add Iridium LEO coverage (global, even inside container terminals) as an alternative or a supplement. A Starnote is a satellite add-on that doesn't operate standalone, so it pairs with a companion Notecard on a Notecarrier XI.
Shock detection is heuristic. The shock_detected alert fires on the
accumulated motion-event count per interval, not on a calibrated peak-G
threshold. If you're shipping fragile biologics or other fragile cargo that
needs documented peak-G evidence, add a dedicated calibrated shock recorder
alongside this device.
Alert cooldowns limit excursion record density. The 30-minute cooldown
suppresses repeat alerts during sustained excursions, which keeps noise down on
the on-call channel. The cargo_data.qo hourly summaries and cargo_log.qo
per-sample entries cover the gap, since a complete excursion shows up in both
the summary min/max fields and the per-sample temp_c values in the log. For
excursion documentation, compliance systems should use the log and summaries,
not just the alerts.
Chain hash is integrity evidence within each boot segment, not cryptographic
proof. The chain_crc is a 32-bit non-cryptographic hash replayed within each
boot_seg. It catches accidental corruption and gives you evidence against
casual tampering with the log contents within a segment. Any cold boot, planned
or uncontrolled, starts a new boot_seg and resets seq and chain_crc to 0.
The remote log shows the new boot_seg value at the boundary, which leaves a
traceable record of the reset. An uncontrolled cold boot (power loss,
brown-out) also clears the RAM-resident ColdChainState. That loses the
in-progress accumulator state and leaves a visible boot_seg increment, and
there's no way to recover the summary accumulators or alert cooldown timestamps
from the interrupted cycle. The tilt baseline orientation is preserved
across uncontrolled cold boots through chain_boot.dbx, so tilt detection never
quietly re-seeds from the post-reboot orientation. If your deployment needs
strong cryptographic tamper evidence across boot boundaries, add a server-side
keyed MAC or timestamping service on top of the chain.
PT100 calibration document must be sourced at procurement. The MAX31865 hardware can deliver NIST-traceable accuracy, but you only get traceability if the PT100 probe comes with a calibration certificate from an accredited laboratory. Order the specified Omega PR-21C-3-100-A-1/8-0600-M12-2 with the CAL-3 NIST-traceable calibration option (performed per ISO 10012-1 / ANSI/NCSL Z540-1), and check the calibration documentation when you buy it. A probe shipped without it doesn't meet the traceability requirement, whatever its accuracy class.
No GPS location. Notecard for Skylo includes GNSS for location, but this
firmware doesn't ask for GPS fixes. Adding card.location.mode and putting
position in the summary and state-change Notes is a natural next step for
multi-modal shipments, where where a temperature excursion or handling event
happened matters as much as when.
Lithium battery transport compliance. The 3.7 V / 2 000 mAh Li-Po cell (7.4 Wh) falls under transport safety regulations on every mode of carriage. That means UN 38.3 testing, IATA Section II (Packing Instruction 966/967) for air, and IMDG Code Class 9 for ocean. Before the first shipment, work out compliance and where your UN 38.3 documentation will come from with your freight forwarder. For volume deployments, buy cells from a manufacturer that supplies UN 38.3 test documentation on request.
Production Next Steps
Once the logger has proven itself on real shipments, these are the extensions that move it toward a production cold-chain evidence system.
GPS context on every record adds GPS fixes to the summary and state-change
Notes (card.location.mode + card.location), so excursion and handling
records show where they happened.
Over-the-air firmware updates come from wiring up Notecard Outboard DFU, so firmware can be updated remotely.
Mean Kinetic Temperature calculation implements MKT in firmware or as a Notehub JSONata transform. MKT is the single-value thermal summary most pharma quality systems require to make release decisions after transit.
Boot-segment cross-referencing adds the boot_seg counter to
cargo_data.qo summaries (right now it's only in cargo_log.qo), so
compliance systems can line summary records up with the log's boot-segment
boundaries without querying the raw log.
Cryptographic tamper evidence means evaluating a server-side keyed MAC or
timestamping service on top of the chain_crc, for deployments that need
strong tamper evidence.
12. Summary
The pharma quality manager (or the food shipper's compliance lead) can finally
answer "what actually happened to this pallet?" with data that's independent of
the carrier, tied to the BOL, and accurate enough to back a real decision to
accept or reject the load. A NIST-traceable PT100 probe delivers ±0.15 °C
readings. The tamper-evident chain in cargo_log.qo lets a downstream verifier
replay every five-minute sample within each boot segment and confirm that
nothing was inserted, deleted, or modified in transit. On every leg of the
trip, Notecard for Skylo picks cellular, NTN satellite, or WiFi, queues through
the coverage gaps, and sends the original on-pallet timestamps once a radio
comes back. Thresholds, sample cadence, summary cadence, and state-model
parameters all retune from Notehub without a reflash. That's how the same
logger handles biologics on an ocean leg and fresh produce on a cross-country
truck.
On This Page
- 1. Project Overview
- 2. System Architecture
- 3. Technical Summary
- 4. Hardware Requirements
- 5. Wiring and Assembly
- 6. Notehub Setup
- 7. Firmware Design
- 7.1 Installing and flashing
- 7.2 Modules
- 7.3 Sensor reading strategy
- 7.4 Event payload design
- 7.5 Low-power and satellite strategy
- 7.6 Retry and error handling
- 7.7 Key code snippet 1: compact log template
- 7.8 Key code snippet 2: integrity chain hash update
- 7.9 Key code snippet 3: shipment-state detection and adaptive batching
- 7.10 Key code snippet 4: sleeping until the Notecard raises ATTN
- 8. Data Flow
- 9. Validation and Testing
- 10. Troubleshooting
- 11. Limitations and Next Steps
- 12. Summary