Use Your AI Agent to Talk to Your Products with Notehub IQ

Blues Developers
What’s New
Resources
API Reference
Notecard and Notehub API documentation
Blog
Technical articles for developers
Connected Product Guidebook
In-depth guides for connected product development
Developer Certification
Get certified on wireless connectivity with Blues
Newsletter
The monthly Blues developer newsletter
Terminal
Connect to a Notecard in your browser
Webinars
Listing of Blues technical webinars
Blues.comNotehub
Shop
Docs
Help
Support DocsNotehub StatusSecurity AdvisoriesVisit our Forum
Sign In
Docs Home
What’s New
Resources
API Reference
Notecard and Notehub API documentation
Blog
Technical articles for developers
Connected Product Guidebook
In-depth guides for connected product development
Developer Certification
Get certified on wireless connectivity with Blues
Newsletter
The monthly Blues developer newsletter
Terminal
Connect to a Notecard in your browser
Webinars
Listing of Blues technical webinars
Blues.comNotehub
Shop
Docs
Example Apps
Samples
Building a Simple Asset Tracker
Building Edge AI Applications
Continuous Asset Tracking with External GPS and Immediate Location Sync
Controlling Device State and Receiving Acknowledgment
Managing a Notecard's WiFi Network Remotely
Putting a Host to Sleep Between Sensor Readings
Routing Data from Notehub to a Custom Cloud Endpoint
Using LEDs and NeoPixels to Monitor Notecard Status
Accelerators
Asset Location Tracking
Heavy Equipment Hours-of-Use & Utilization Tracker
Returnable Container / Tote Pool Tracker
Untethered Trailer and Chassis Fleet Tracker
Asset Performance Optimization
Legacy Diesel Generator Fleet Performance Uplift
Solar Array String-Level Performance Dashboard
Battery Management Systems
Aerial Lift / Rental Equipment Battery Health Monitor
Off-Grid Solar Battery Site Controller
Remote Cabinet Backup Battery Sentinel
Downtime Prevention
Municipal Wastewater Lift Station Monitor
Rooftop HVAC Predictive Maintenance
VFD Pump Predictive Maintenance
Energy Monitoring Solutions
Commercial Tenant Sub-Metering Bridge
EV Charger Session & Utilization Monitor
Utility Distribution Transformer Load Monitor
Energy Savings
Commercial Plug-Load & After-Hours Waste Dashboard
Demand-Response Solar + Battery Dispatcher
Multi-Site Walk-In Cooler Energy & Setpoint Monitor
Industrial Equipment Monitoring
CNC Machine Spindle Load and Cycle Time Tracker
Injection Molding Shot-to-Shot Process Monitor
Trailer Manufacturer Connected Trailer Platform
Loss Prevention
Construction Equipment Anti-Theft Tracker with Immobilizer
Reefer Trailer Cold-Chain & Door-Event Monitor
Remote Patient Monitoring
Cellular Medication Adherence Pillbox
Post-Discharge Vitals Relay Hub
Remotely Monitored Assets
Off-Grid Livestock Water Tank Monitor
Remote Apiary Hive Health Monitor
Safety Assurance
Construction Site Environmental & Noise Exposure Monitor
Lone Worker Panic and Fall Detection Beacon
Regulatory-Grade Pharmacy and Lab Cold Storage Audit Monitor
Supply Chain Tracking
Pallet-Attached Cold Chain Logger
Rail Car Condition & Interchange Tracker
Truck Roll Reduction
Commercial Grease Interceptor Level Monitor
Propane / LPG Tank Fill Telemetry
homechevron_rightExample Appschevron_rightAcceleratorschevron_rightPallet-Attached Cold Chain Logger

Pallet-Attached Cold Chain Logger

Pallet-Attached Cold Chain Logger banner
note

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

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_samples consecutive low-motion samples (motion < transit_motion_min per interval). During dwell, the firmware multiplies both the cargo_data.qo summary interval and the Notecard's hub.set outbound cadence by dwell_batch_factor (4× by default). That means fewer summary Notes queued per session and fewer outbound sessions per hour. applyDynamicOutbound() re-issues hub.set whenever the state moves in or out of DWELL.
  • IN_TRANSIT: confirmed after transit_confirm_samples consecutive 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

  1. Notehub: create a Notehub project and copy its ProductUID.
  2. 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.
  3. Edit one line in firmware/cargo_cold_chain_monitor/cargo_cold_chain_monitor_helpers.h: uncomment the #define PRODUCT_UID line and set it to your Notehub project's value.
  4. Flash:
    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/
    The usb=none board option turns off the USB CDC stack, which has to be off for the host to sleep. Full instructions are in §7.1.
  5. Watch: open Notehub → your project → Events tab. _session.qo shows up on first contact and cargo_data.qo after the first summary interval. cargo_log.qo entries arrive in batches with each outbound sync, cargo_state.qo appears on the first confirmed dwell or motion event, and any threshold trip shows up as cargo_alert.qo within 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.qo within 1–2 minutes over cellular or WiFi, or within several minutes over Skylo NTN with a clear sky. The first cargo_log.qo entry shows up one sample interval later (~5 minutes). Expect the first cargo_data.qo summary ~60 minutes after the device gets a valid epoch from Notehub's card.time API.

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

PartQtyRationale
Notecarrier CX1Integrated 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)1Single 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 certificate1IEC 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)1SPI 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)1Sensirion 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)116-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–1Coulomb 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)1Portable 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 flanges1IP67-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)1The 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)1Lets 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 rollSeals 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)1Optical 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)2You 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

Wiring and assembly: MAX31865 on SPI with PT100 probe; SHT41 and VEML7700 on Qwiic I²C; ATTN → D5 jumper so the Notecard can wake the host from STOP2; Skylo antenna on MAIN u.FL; LiPo via Mojo (bench) to +VBAT

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 pinHost pinHeader label
VIN3V3+3V3
GNDGNDGND
CLKSCKSCK
SDO (MISO)MISOMISO
SDI (MOSI)MOSIMOSI
CSD10CS / 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):

SensorI²C address
SHT410x44 (fixed)
VEML77000x10 (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 LOAD output (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, and sensitivity:2. It doesn't set the sample rate or G-range, because the Notecard manages those internally. The card.motion response supplies per-bucket motion counts for shock detection and the current orientation (its status field) for tilt detection.

6. Notehub Setup

  1. 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.

  2. 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.

  3. 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.wifi request 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.

  1. 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-8c vs fresh-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.

  2. 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.get reads 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 is INBOUND_INTERVAL_MIN = 720 (12 hours). During commissioning, force a sync by clicking Sync on the device's detail page, or drop INBOUND_INTERVAL_MIN to 60 for the bench session and put it back before field deployment.

    VariableDefaultPurpose
    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_high fires above it, and temp_low fires below temp_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_exposure fires 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_detected fires. 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.qo summary Notes, stretched by dwell_batch_factor during confirmed DWELL. This sets how often summaries are generated. The outbound sync interval is the compiled OUTBOUND_INTERVAL_MIN constant (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_min and 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.
  3. Configure routes. Add one route for cargo_alert.qo and cargo_state.qo (to a TMS webhook or on-call endpoint), one for cargo_data.qo (to a cold-chain analytics platform), and one for cargo_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 -9999 means 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
    }

    _time is always present. Entries logged before the Notecard had a valid epoch carry _time = 0 as a pre-sync sentinel, so use Notehub's event receive-time as the approximation for those records. motion_valid is 1 when card.motion returned valid data and 0 when the accelerometer interface was unavailable. Notehub leaves zero-valued fields out of templated Notes, so a pallet that didn't move (like this one) shows motion_valid with no motion field. An entry with neither field means the motion data was missing, not that nothing moved. state maps to 0=unknown, 1=dwell, 2=in_transit, and 3=handling. boot_seg goes up on every cold boot, and all entries with the same boot_seg form one continuous chain segment. Within a segment, a gap in seq points to a missed entry. A chain_crc mismatch (when replaying the chain within one boot_seg from seq=1, seed=0) points to a modified or inserted record. When a new boot_seg value 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. The alert field is one of temp_low, temp_high, humidity_high, shock_detected, light_exposure, or tilt_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_from and orientation_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 exactly 0. So in an alert, a missing lux or motion field means either a sensor fault or a zero reading. -9999 only shows up in cargo_data.qo and cargo_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 URL https://github.com/stm32duino/BoardManagerFiles/raw/main/package_stmicroelectronics_index.json under File → Preferences → Additional Boards Manager URLs, then search for STM32 MCU based boards.
  • Blues Wireless Notecard library (note-arduino). Install it with arduino-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 dependency STM32duino RTC): stm32duino/STM32LowPower. Install it from the Library Manager. It provides the STOP2 entry and ATTN-pin wake that cx_sleep.h uses.

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.yaml sets the same board options, so running arduino-cli compile in that directory without -b picks them up too. Replace /dev/cu.usbmodem* with your actual port (COMx on 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

ResponsibilityWhere in code
Notecard configuration (hub.set, card.transport, motion mode) with warm-boot retrynotecardConfigure() + flags in ColdChainState
Compact template definition for all four NotefilesdefineTemplates() + templates_registered flag
Environment-variable fetch, clamp, and cadence-change window resetfetchEnvOverrides(), envFloat() helper
PT100/MAX31865 temperature reads (SPI)readSensors()
SHT41 relative humidity reads (I²C, humidity channel only)readSensors()
VEML7700 interior cargo-bay light readsreadSensors()
Notecard built-in accelerometer motion count + orientationreadMotionCount()
Shipment-state detection (DWELL / IN_TRANSIT / HANDLING)detectShipmentState()
State-transition immediate-sync NotesendStateChange()
Tamper-evident per-sample log entry (seq, chain_crc)sendLogEntry()
Alert cooldown logicalertCooldownOk()
Threshold evaluation and alert dispatchevaluateAlerts(), sendAlert(), sendTiltAlert()
Rolling sample accumulationaccumulateSample()
Adaptive summary interval (dwell batching), snapshot/retry/discardsnapshotSummary(), 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 timestampscurrentEpoch()

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 sets temp_c = INVALID_F and 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 the MAX31865_RREF and MAX31865_RNOMINAL constants in helpers.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_2 and integration time VEML7700_IT_100MS favor sensitivity, which suits the near-zero lux levels you'd expect inside a sealed reefer or container. A missing VEML7700 returns INVALID_F (not 0.0), so you can tell a sensor fault from real darkness. When lux is INVALID_F, both the light_exposure alert and HANDLING state detection are skipped.
  • Accelerometer. The firmware calls card.motion with minutes: gSampleSec / 60 to get a non-overlapping window that covers exactly the elapsed sample interval. Env-var processing always clamps sample_interval_sec to a whole number of minutes, so the division comes out exact. The response's movements string holds one base-36 character per bucket (0–9, then A–Z for 10–35, and * for more than 35), and the firmware adds them up, counting * as 36. The current orientation comes from the response's status field. A value of unavailable (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 four note.template registrations run in setup() at power-up. Each one sets a flag in ColdChainState when it succeeds and gets retried on later wakes until it's confirmed. Every reset zeroes ColdChainState, so a reflash always reapplies all of them, and a new PRODUCT_UID or template schema takes effect immediately (SCHEMA_VERSION = 6, which added the cargo_alert.qo and cargo_state.qo templates).
  • 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.
  • seq and chain_crc advance before the note.add call in sendLogEntry(), 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.qo note.add fails, the firmware stores it in the ColdChainState.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() leaves pending_epoch set, 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

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_samples consecutive 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_exposure alert 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 or 0 for pre-sync), seq, temp_c, rh_pct, lux, motion, motion_valid, state, boot_seg, and chain_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, the motion_valid flag, and the sample count.
  • cargo_state.qo: sent on every DWELL / IN_TRANSIT / HANDLING state transition, with sync:true.
  • cargo_alert.qo: sent only when a threshold trips, with sync: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 below temp_min_c (default 2.0 °C), measured by the PT100.
  • temp_high: temperature above temp_max_c (default 8.0 °C), measured by the PT100.
  • humidity_high: relative humidity above humidity_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 adds orientation_from and orientation_to fields 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:

PhaseRadioSourceExpected
Host + sensors active (~5–10 seconds per wake)n/aFull-system estimate15–30 mA
Host in STOP2 + Notecard idle (between radio sessions)n/aNOTE-NBGLWX datasheet + STM32L433 STOP2 figure~10 µA (Notecard ~8 µA + host ~1–2 µA); < 0.1 mA total system
Hourly outbound sync, small Note batchLTE-M / 2GFull-system estimate150–300 mA average, 15–45 s burst
Hourly outbound sync, small Note batchWiFiFull-system estimate80–150 mA average, 5–20 s burst
Satellite session (NTN)Skylo NTNFull-system estimate250–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_factor automatically 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, set dwell_batch_factor to 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→D5 jumper (without it, the firmware logs [cargo] ATTN sleep failed and 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_c to 10.0 and sync. (It has to stay above temp_min_c, or the firmware ignores the pair.) On the next wake (within sample_interval_sec seconds), the room-temperature bench is over the threshold and the device emits cargo_alert.qo with alert=temp_high. Put the value back when you're done.
  • Light-exposure / door-open alert: Set light_open_lux to 1.0, sync, and shine a flashlight through the cargo-bay-facing lens window. On the next wake you should see cargo_alert.qo with alert=light_exposure and cargo_state.qo with state_to=handling.
  • Shock alert: Set shock_events to 2, sync, and tap the assembly firmly. A shock_detected alert should arrive on the next wake.
  • Tilt alert: Let the baseline orientation get set (you'll see [cargo] orientation baseline set: face-up on the serial monitor), then rotate the assembly. Confirm a tilt_detected alert with orientation_from and orientation_to fields.
  • Dwell → in-transit transition: Leave the unit still for ≥ 3 sample cycles (the dwell_confirm_samples default) and confirm cargo_state.qo with state_to=dwell. Then move the unit briskly for 2 sample cycles and confirm cargo_state.qo with state_to=in_transit.
  • Chain integrity: Pull a run of cargo_log.qo entries from Notehub and group them by boot_seg. Within each group, replay the chain hash from seq=1, seed=0 against the recorded field values, using the same algorithm (chainUpdate with boot_seg as a parameter). The final chain_crc you compute for each group should match the last entry. A new boot_seg value resets the replay to seed=0.

10. Troubleshooting

SymptomLikely CauseResolution
Serial monitor shows [cargo] MAX31865 fault 0x... on cold bootA 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 minutesNo 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 messagePRODUCT_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 foreverThe 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 breachThat 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 lineMAX31865 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
    • What to expect in Notehub
  • 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
    • Remaining Design Boundaries
    • Production Next Steps
  • 12. Summary

Resources

  • GitHub
  • Request More Info
Can we improve this page? Send us feedback
© 2026 Blues Inc.
© 2026 Blues Inc.
AboutDocsAPI ReferenceTermsPrivacy