50  Sigfox Hands-On Lab

In 60 Seconds

This hands-on lab uses an ESP32 simulation and Python to practice Sigfox concepts: encoding data into 12-byte payloads, managing the 140 message/day budget, measuring power consumption patterns, and calculating battery life for multi-year deployments with different messaging strategies.

50.1 Introduction

⏱️ ~25 min | ⭐⭐ Intermediate | 📋 P09.C11.U04

This hands-on chapter provides practical experience with Sigfox concepts through an interactive ESP32 simulation and Python implementations. You’ll learn how Sigfox handles message constraints, implements low-power operation, and manages daily message budgets.

Learning Objectives

By the end of this lab, you will be able to:

  • Implement Sigfox 12-byte payload encoding using packed binary structs and fixed-point scaling
  • Analyze daily message budget utilization against the 140-message subscription limit
  • Diagnose power consumption bottlenecks by comparing TX, RX, and sleep current contributions
  • Calculate battery life for different messaging scenarios using energy budget formulas
  • Construct payload encoders and decoders in Python for environmental and GPS data types

Key Concepts

  • Sigfox Development Kit: Hardware platforms for Sigfox prototyping including Arduino shields, evaluation boards, and certified development modules.
  • AT Commands: Serial command interface used to control Sigfox modules; standard commands include AT\(SF (send frame), AT\)DR (data rate query), and AT$T (temperature read).
  • Payload Construction: Building binary payloads for Sigfox using byte manipulation, struct packing, and hexadecimal encoding in firmware code.
  • Backend Testing: Verifying message reception using Sigfox backend portal, checking device statistics, and validating callback delivery to application server.
  • Signal Testing: Measuring received signal quality using backend RSSI data from multiple base stations to assess coverage at test locations.
  • Hands-on Workflow: Practical development cycle from hardware setup, firmware compilation, device registration, message sending, and backend verification.
  • Debugging Tools: Methods for troubleshooting Sigfox implementations including AT command testing, backend console inspection, and RF spectrum analysis.

This lab guides you through sending your first Sigfox messages and viewing them in the Sigfox cloud platform. You will register a device, transmit sensor data, and see it appear on a dashboard. It is the fastest way to experience how Sigfox turns tiny radio messages from remote sensors into usable data.

“Time to get our hands dirty!” Sammy the Sensor announced excitedly. “In this lab, we build a simulated Sigfox device on an ESP32. I read the temperature and humidity, then we pack everything into exactly 12 bytes – temperature as a scaled integer, humidity as a percentage, battery voltage, message count, status flags, and a timestamp. Every byte counts when you only have twelve!”

“Watch the OLED display as we run the simulation,” Lila the LED said. “You will see the message counter climb toward 140. When it hits the daily limit, I turn red to show that no more messages can be sent. The network just silently drops anything extra. It is a powerful visual reminder of why you need to budget your messages carefully.”

Max the Microcontroller pointed at the code. “Notice how the payload struct uses packed integers – int16 for temperature multiplied by 100, uint16 for humidity, uint8 for flags. We fit five sensor readings plus a timestamp into exactly 12 bytes. Compare that to sending the same data as text: ‘temp=22.5,hum=65.0’ would be 18 characters – already over the limit! Binary encoding is the secret to making Sigfox work.”

“The energy tracking in this lab is my favorite part,” Bella the Battery added. “Each message is transmitted 3 times at 40 milliamps for 2 seconds each – that is 6 seconds total, using 0.067 milliamp-hours per message. With a 2,000 milliamp-hour battery and 24 messages per day, that gives us about 3.4 years of life! But push it to 140 messages per day and battery life drops to just 7 months. This lab lets you experiment with those trade-offs in real time.”

50.2 Prerequisites

Before this chapter, ensure you’ve completed:


50.3 Hands-On Lab: Sigfox Communication Simulator

This interactive lab simulates Sigfox ultra-narrowband communication using an ESP32. You’ll learn how Sigfox handles severe message constraints, implements low-power operation, and manages daily message budgets.

Lab Objectives
  • Apply Sigfox’s 12-byte uplink payload limitation to a real sensor data packing problem
  • Implement efficient binary payload encoding using scaled integers and bit flags
  • Assess daily message budget utilization under different reporting intervals
  • Diagnose power consumption patterns by isolating TX, sleep, and RX energy contributions
  • Demonstrate ultra-narrowband communication trade-offs through RSSI and timing observations
  • Configure retry strategies for message transmission failures within budget constraints

50.3.1 Wokwi Simulation

Simulation Features

Hardware Components:

  • ESP32 microcontroller (simulating Sigfox module)
  • DHT22 temperature/humidity sensor
  • OLED display (128x64) for status information
  • Push button for manual message transmission
  • LED indicators for TX/RX/Error status

Sigfox Characteristics Simulated:

  • 12-byte maximum payload size
  • 140 messages per day limit
  • Ultra-narrowband modulation (100 bps)
  • Low power sleep modes
  • Message transmission timing
  • Downlink message windows (4 messages/day)
  • Coverage simulation with RSSI indication

Interactive Controls:

  • Button: Send immediate message (counts toward daily limit)
  • Serial Monitor: View detailed transmission logs
  • OLED: Real-time status and statistics

Learning Outcomes:

  • Optimize sensor data to fit 12-byte payload
  • Manage daily message budget strategically
  • Implement power-efficient transmission scheduling
  • Handle transmission failures gracefully
  • Understand ultra-narrowband trade-offs

Lab Instructions
  1. Click “Start Simulation” in the Wokwi window above
  2. Observe the OLED display showing:
    • Current sensor readings (temperature, humidity, battery)
    • Messages sent today / daily limit (140)
    • Next scheduled transmission time
    • TX power state and RSSI signal strength
  3. Press the button to send an immediate message (watch payload encoding)
  4. Monitor Serial output for detailed logs:
    • Payload byte breakdown
    • Message transmission timing
    • Power consumption estimates
    • Success/failure indicators
  5. Watch LED indicators:
    • Blue LED: Message transmission in progress
    • Green LED: Successful transmission
    • Red LED: Transmission failure or daily limit exceeded
  6. Experiment with scenarios:
    • Observe how the 12-byte payload is efficiently packed
    • Watch the daily message counter increment
    • See what happens when approaching the 140 message limit
    • Notice the ultra-low power sleep periods between transmissions

50.3.2 Wokwi Code

Copy and paste this code into the Wokwi editor:

diagram.json (Hardware Configuration):

{
  "version": 1,
  "author": "IoT Class - Sigfox Lab",
  "editor": "wokwi",
  "parts": [
    { "type": "wokwi-esp32-devkit-v1", "id": "esp32", "top": 0, "left": 0, "attrs": {} },
    { "type": "wokwi-dht22", "id": "dht1", "top": -30, "left": 120, "attrs": {} },
    { "type": "wokwi-ssd1306", "id": "oled1", "top": -160, "left": -120, "attrs": { "i2cAddress": "0x3c" } },
    { "type": "wokwi-pushbutton", "id": "btn1", "top": 100, "left": -150, "attrs": { "color": "green" } },
    { "type": "wokwi-led", "id": "led_tx", "top": 180, "left": -140, "attrs": { "color": "blue" } },
    { "type": "wokwi-led", "id": "led_rx", "top": 180, "left": -100, "attrs": { "color": "green" } },
    { "type": "wokwi-led", "id": "led_err", "top": 180, "left": -60, "attrs": { "color": "red" } },
    { "type": "wokwi-resistor", "id": "r1", "top": 200, "left": -140, "attrs": { "value": "220" } },
    { "type": "wokwi-resistor", "id": "r2", "top": 200, "left": -100, "attrs": { "value": "220" } },
    { "type": "wokwi-resistor", "id": "r3", "top": 200, "left": -60, "attrs": { "value": "220" } }
  ],
  "connections": [
    [ "esp32:TX", "$serialMonitor:RX", "", [] ],
    [ "esp32:RX", "$serialMonitor:TX", "", [] ],
    [ "dht1:VCC", "esp32:3V3", "red", [ "v0" ] ],
    [ "dht1:GND", "esp32:GND.1", "black", [ "v0" ] ],
    [ "dht1:SDA", "esp32:D15", "green", [ "v0" ] ],
    [ "oled1:VCC", "esp32:3V3", "red", [ "v0" ] ],
    [ "oled1:GND", "esp32:GND.2", "black", [ "v0" ] ],
    [ "oled1:SCL", "esp32:D22", "yellow", [ "v0" ] ],
    [ "oled1:SDA", "esp32:D21", "green", [ "v0" ] ],
    [ "btn1:1.l", "esp32:D4", "blue", [ "v0" ] ],
    [ "btn1:2.l", "esp32:GND.1", "black", [ "v0" ] ],
    [ "led_tx:A", "esp32:D25", "blue", [ "v0" ] ],
    [ "led_tx:C", "r1:1", "blue", [ "v0" ] ],
    [ "r1:2", "esp32:GND.1", "black", [ "v0" ] ],
    [ "led_rx:A", "esp32:D26", "green", [ "v0" ] ],
    [ "led_rx:C", "r2:1", "green", [ "v0" ] ],
    [ "r2:2", "esp32:GND.1", "black", [ "v0" ] ],
    [ "led_err:A", "esp32:D27", "red", [ "v0" ] ],
    [ "led_err:C", "r3:1", "red", [ "v0" ] ],
    [ "r3:2", "esp32:GND.1", "black", [ "v0" ] ]
  ]
}

sketch.ino (Main Code):

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <DHT.h>

// Hardware Pin Definitions
#define DHT_PIN 15
#define BUTTON_PIN 4
#define LED_TX_PIN 25
#define LED_RX_PIN 26
#define LED_ERR_PIN 27
#define OLED_WIDTH 128
#define OLED_HEIGHT 64
#define OLED_RESET -1

// Sigfox Constants
#define SIGFOX_MAX_PAYLOAD 12        // 12 bytes maximum
#define SIGFOX_DAILY_LIMIT 140       // 140 messages per day uplink
#define SIGFOX_DOWNLINK_LIMIT 4      // 4 messages per day downlink
#define SIGFOX_TX_TIME_MS 2000       // ~2 seconds transmission time (100 bps)
#define SIGFOX_TX_CURRENT_MA 40      // 40mA during transmission
#define SIGFOX_SLEEP_CURRENT_UA 1    // 1uA in deep sleep
#define SIGFOX_FREQ_EU 868           // MHz (Europe)
#define SIGFOX_BANDWIDTH 100         // Hz ultra-narrowband
#define SIGFOX_DATARATE 100          // bps

// Simulation Parameters
#define MESSAGE_INTERVAL_DEMO_MS 15000  // 15 seconds for simulation
#define BATTERY_CAPACITY_MAH 2000    // 2000mAh battery
#define MIN_RSSI -142                // Sigfox receiver sensitivity
#define MAX_RSSI -110                // Strong signal

// Global Objects
Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, OLED_RESET);
DHT dht(DHT_PIN, DHT22);

// Sigfox State
struct SigfoxState {
  uint16_t messagesSentToday;
  uint16_t messagesFailedToday;
  uint16_t downlinksReceivedToday;
  uint32_t nextTransmissionMs;
  float batteryVoltage;
  float totalEnergyUsed_mAh;
  int8_t lastRSSI;
  bool coverageAvailable;
  uint32_t messageCounter;
} sigfoxState;

// Sensor Data Structure (packed into 12 bytes)
struct __attribute__((packed)) SigfoxPayload {
  int16_t temperature;     // 2 bytes: temp * 100
  uint16_t humidity;       // 2 bytes: humidity * 100
  uint16_t battery;        // 2 bytes: voltage * 1000
  uint8_t messageCount;    // 1 byte: daily message counter
  uint8_t flags;           // 1 byte: status flags
  uint32_t timestamp;      // 4 bytes: seconds since boot
  // Total: 12 bytes exactly
};

// Status Flags
enum StatusFlags {
  FLAG_LOW_BATTERY = 0x01,
  FLAG_COVERAGE_OK = 0x02,
  FLAG_TEMP_ALERT = 0x04,
  FLAG_HUMIDITY_ALERT = 0x08,
  FLAG_BUTTON_PRESSED = 0x10
};

// Current Sensor Values
float currentTemp = 0.0;
float currentHumidity = 0.0;
unsigned long lastButtonPress = 0;
const unsigned long debounceDelay = 300;

void setup() {
  Serial.begin(115200);
  delay(1000);

  Serial.println("\n\n======================================");
  Serial.println("   SIGFOX COMMUNICATION SIMULATOR");
  Serial.println("======================================\n");

  initHardware();

  // Initialize Sigfox state
  sigfoxState.messagesSentToday = 0;
  sigfoxState.messagesFailedToday = 0;
  sigfoxState.downlinksReceivedToday = 0;
  sigfoxState.batteryVoltage = 3.7;
  sigfoxState.totalEnergyUsed_mAh = 0.0;
  sigfoxState.coverageAvailable = true;
  sigfoxState.nextTransmissionMs = millis() + MESSAGE_INTERVAL_DEMO_MS;
  sigfoxState.messageCounter = 0;
  sigfoxState.lastRSSI = -120;

  Serial.println("Sigfox module initialized");
  Serial.printf("Region: Europe (%d MHz)\n", SIGFOX_FREQ_EU);
  Serial.printf("Bandwidth: %d Hz (ultra-narrowband)\n", SIGFOX_BANDWIDTH);
  Serial.printf("Daily limit: %d uplink / %d downlink\n",
                SIGFOX_DAILY_LIMIT, SIGFOX_DOWNLINK_LIMIT);
  Serial.printf("Max payload: %d bytes\n\n", SIGFOX_MAX_PAYLOAD);

  updateDisplay();
}

void loop() {
  unsigned long currentMs = millis();

  checkButton();
  simulateCoverage();
  updateSensors();

  if (currentMs >= sigfoxState.nextTransmissionMs) {
    transmitSigfoxMessage(false);
    sigfoxState.nextTransmissionMs = currentMs + MESSAGE_INTERVAL_DEMO_MS;
  }

  updateDisplay();

  // Reset daily counters (simulated: every 30 messages)
  if (sigfoxState.messagesSentToday >= 30) {
    resetDailyCounters();
  }

  delay(100);
}

void initHardware() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_TX_PIN, OUTPUT);
  pinMode(LED_RX_PIN, OUTPUT);
  pinMode(LED_ERR_PIN, OUTPUT);

  Wire.begin(21, 22);
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("ERROR: OLED initialization failed!");
    while (1) delay(100);
  }

  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("Sigfox Module");
  display.println("Initializing...");
  display.display();

  dht.begin();
  delay(2000);
}

void updateSensors() {
  currentTemp = dht.readTemperature();
  currentHumidity = dht.readHumidity();

  if (isnan(currentTemp) || isnan(currentHumidity)) {
    currentTemp = 22.5 + (random(-50, 50) / 10.0);
    currentHumidity = 65.0 + (random(-100, 100) / 10.0);
  }

  sigfoxState.batteryVoltage -= 0.0001;
  if (sigfoxState.batteryVoltage < 3.0) {
    sigfoxState.batteryVoltage = 3.7;
  }
}

void transmitSigfoxMessage(bool manualTrigger) {
  if (sigfoxState.messagesSentToday >= SIGFOX_DAILY_LIMIT) {
    Serial.println("\nDAILY MESSAGE LIMIT REACHED!");
    Serial.printf("Already sent %d/%d messages today\n",
                  sigfoxState.messagesSentToday, SIGFOX_DAILY_LIMIT);
    blinkLED(LED_ERR_PIN, 1000);
    return;
  }

  if (!sigfoxState.coverageAvailable) {
    Serial.println("\nNO SIGFOX COVERAGE DETECTED");
    sigfoxState.messagesFailedToday++;
    blinkLED(LED_ERR_PIN, 500);
    return;
  }

  Serial.println("\n=== SIGFOX TRANSMISSION ===");

  sigfoxState.messageCounter++;

  // Prepare 12-byte payload
  SigfoxPayload payload;
  payload.temperature = (int16_t)(currentTemp * 100);
  payload.humidity = (uint16_t)(currentHumidity * 100);
  payload.battery = (uint16_t)(sigfoxState.batteryVoltage * 1000);
  payload.messageCount = sigfoxState.messagesSentToday + 1;
  payload.timestamp = millis() / 1000;

  payload.flags = 0;
  if (sigfoxState.batteryVoltage < 3.3) payload.flags |= FLAG_LOW_BATTERY;
  if (sigfoxState.coverageAvailable) payload.flags |= FLAG_COVERAGE_OK;
  if (currentTemp > 30.0 || currentTemp < 10.0) payload.flags |= FLAG_TEMP_ALERT;
  if (currentHumidity > 80.0 || currentHumidity < 30.0) payload.flags |= FLAG_HUMIDITY_ALERT;
  if (manualTrigger) payload.flags |= FLAG_BUTTON_PRESSED;

  printPayloadDetails(payload);

  Serial.printf("\nTransmitting via Sigfox...\n");
  Serial.printf("Frequency: %d MHz | Bandwidth: %d Hz | Data rate: %d bps\n",
                SIGFOX_FREQ_EU, SIGFOX_BANDWIDTH, SIGFOX_DATARATE);

  blinkLED(LED_TX_PIN, SIGFOX_TX_TIME_MS);

  float txEnergy_mAh = (SIGFOX_TX_CURRENT_MA * SIGFOX_TX_TIME_MS) / 3600000.0;
  sigfoxState.totalEnergyUsed_mAh += txEnergy_mAh;

  Serial.printf("\nEnergy: %.4f mAh (Total: %.2f mAh)\n",
                txEnergy_mAh, sigfoxState.totalEnergyUsed_mAh);

  bool txSuccess = (random(100) < 95);

  if (txSuccess) {
    Serial.println("Transmission successful!");
    sigfoxState.messagesSentToday++;
    blinkLED(LED_RX_PIN, 500);
  } else {
    Serial.println("Transmission failed!");
    sigfoxState.messagesFailedToday++;
    blinkLED(LED_ERR_PIN, 500);
  }

  float estimatedLifeDays = calculateBatteryLife();
  Serial.printf("Battery life estimate: %.1f years\n", estimatedLifeDays / 365.0);
  Serial.printf("Messages today: %d/%d (%.1f%%)\n",
                sigfoxState.messagesSentToday, SIGFOX_DAILY_LIMIT,
                (sigfoxState.messagesSentToday * 100.0) / SIGFOX_DAILY_LIMIT);
}

void printPayloadDetails(SigfoxPayload &payload) {
  Serial.println("\nPayload (12 bytes):");
  Serial.printf("  Temperature: %.2fC (encoded: %d)\n", currentTemp, payload.temperature);
  Serial.printf("  Humidity:    %.2f%% (encoded: %d)\n", currentHumidity, payload.humidity);
  Serial.printf("  Battery:     %.2fV (encoded: %d)\n", sigfoxState.batteryVoltage, payload.battery);
  Serial.printf("  Msg Count:   %d\n", payload.messageCount);
  Serial.printf("  Flags:       0x%02X\n", payload.flags);
  Serial.printf("  Timestamp:   %lu sec\n", payload.timestamp);

  Serial.printf("  Raw hex: ");
  uint8_t* bytes = (uint8_t*)&payload;
  for (int i = 0; i < sizeof(SigfoxPayload); i++) {
    Serial.printf("%02X ", bytes[i]);
  }
  Serial.printf("\n  Size: %d bytes (%.1f%% utilization)\n",
                sizeof(SigfoxPayload),
                (sizeof(SigfoxPayload) * 100.0) / SIGFOX_MAX_PAYLOAD);
}

void updateDisplay() {
  display.clearDisplay();
  display.setTextSize(1);
  display.setCursor(0, 0);

  display.println("SIGFOX MODULE");
  display.drawLine(0, 10, OLED_WIDTH, 10, SSD1306_WHITE);

  display.setCursor(0, 14);
  display.printf("Temp: %.1fC", currentTemp);
  display.setCursor(0, 24);
  display.printf("Hum:  %.1f%%", currentHumidity);
  display.setCursor(0, 34);
  display.printf("Batt: %.2fV", sigfoxState.batteryVoltage);

  display.setCursor(0, 44);
  display.printf("Msgs: %d/%d", sigfoxState.messagesSentToday, SIGFOX_DAILY_LIMIT);

  display.setCursor(0, 54);
  if (sigfoxState.coverageAvailable) {
    display.printf("RSSI:%ddBm OK", sigfoxState.lastRSSI);
  } else {
    display.print("NO COVERAGE!");
  }

  display.display();
}

void simulateCoverage() {
  sigfoxState.lastRSSI = random(MIN_RSSI - 10, MAX_RSSI);
  sigfoxState.coverageAvailable = (sigfoxState.lastRSSI >= MIN_RSSI);

  if (random(1000) < 10) {
    sigfoxState.coverageAvailable = false;
    sigfoxState.lastRSSI = MIN_RSSI - 15;
  }
}

void checkButton() {
  if (digitalRead(BUTTON_PIN) == LOW) {
    if (millis() - lastButtonPress > debounceDelay) {
      lastButtonPress = millis();
      Serial.println("\n>>> Manual transmission triggered <<<");
      transmitSigfoxMessage(true);
    }
  }
}

void blinkLED(int pin, int duration) {
  digitalWrite(pin, HIGH);
  delay(duration);
  digitalWrite(pin, LOW);
}

void resetDailyCounters() {
  Serial.println("\n=== DAILY COUNTER RESET ===");
  Serial.printf("Yesterday: %d sent, %d failed\n",
                sigfoxState.messagesSentToday,
                sigfoxState.messagesFailedToday);
  sigfoxState.messagesSentToday = 0;
  sigfoxState.messagesFailedToday = 0;
  sigfoxState.downlinksReceivedToday = 0;
}

float calculateBatteryLife() {
  float messagesPerDay = 144;
  if (messagesPerDay > SIGFOX_DAILY_LIMIT) {
    messagesPerDay = SIGFOX_DAILY_LIMIT;
  }

  float txEnergy_mAh = (SIGFOX_TX_CURRENT_MA * SIGFOX_TX_TIME_MS) / 3600000.0;
  float dailyTxEnergy_mAh = txEnergy_mAh * messagesPerDay;
  float sleepHoursPerDay = 24.0 - (messagesPerDay * SIGFOX_TX_TIME_MS / 3600000.0);
  float dailySleepEnergy_mAh = (SIGFOX_SLEEP_CURRENT_UA / 1000.0) * sleepHoursPerDay;
  float dailyTotalEnergy_mAh = dailyTxEnergy_mAh + dailySleepEnergy_mAh;

  return BATTERY_CAPACITY_MAH / dailyTotalEnergy_mAh;
}

Key Sigfox Characteristics Implemented:

  1. 12-Byte Payload Constraint:
    • SigfoxPayload struct is exactly 12 bytes
    • Uses efficient encoding (int16, uint16) instead of float
    • Bit flags pack multiple boolean values into single byte
    • Temperature scaled by 100 (22.5C = 2250)
  2. Daily Message Limit (140):
    • Counter tracks messages sent today
    • Blocks transmission when limit reached
    • Resets at “midnight” (simulated every 30 messages)
  3. Ultra-Narrowband Characteristics:
    • 100 Hz bandwidth (vs. 125 kHz for LoRa)
    • 100 bps data rate (extremely slow)
    • 2-second transmission time simulated
    • High receiver sensitivity (-142 dBm)
  4. Low Power Operation:
    • 40mA during 2-second transmission
    • 1uA sleep current between transmissions
    • Energy tracking shows mAh consumption
    • Battery life calculation (10+ years possible)
  5. Coverage Simulation:
    • RSSI varies between -142 dBm (minimum) and -110 dBm (strong)
    • Coverage loss simulated randomly
    • Transmission blocked when RSSI below sensitivity

Compare with LoRaWAN:

  • Sigfox: 12 bytes, 140 msgs/day, operator network, ultra-simple
  • LoRaWAN: 51-242 bytes, unlimited msgs, private network, more complex

  1. How much of the daily message budget is used by sending data every 10 minutes?
    • Every 10 minutes = 6 messages/hour = 144 messages/day
    • This exceeds the 140 message limit!
    • Solution: Send every 10.3 minutes = 140 messages/day exactly
  2. Why is Sigfox transmission so slow (2 seconds for 12 bytes)?
    • Ultra-narrowband: 100 Hz bandwidth = 100 bps data rate
    • 12 bytes = 96 bits
    • At 100 bps: 96 bits / 100 bps = 0.96 seconds (plus overhead)
    • Trade-off: Slow speed enables extreme range and penetration
  3. What’s the theoretical battery life with 140 messages/day?
    • TX energy per message: 40 mA x 6 sec (3 transmissions x 2 sec each) = 0.067 mAh
    • Daily TX energy: 0.067 x 140 = 9.33 mAh
    • Sleep energy: 0.024 mAh/day (1 uA x 24h)
    • Total: 9.35 mAh/day
    • With 2000 mAh battery: 214 days = 0.6 years
    • With fewer messages (24/day): 0.067 x 24 + 0.024 = 1.63 mAh/day = 3.4 years

Transmission energy scales linearly with message count. Each Sigfox message is transmitted 3 times at 40 mA for 2 seconds each (6 seconds total): \(E_{\text{msg}} = 40\,\text{mA} \times 6\,\text{s} / 3600\,\text{s/h} = 0.067\,\text{mAh}\). For 140 messages/day: \(E_{\text{daily}} = 140 \times 0.067 = 9.33\,\text{mAh/day}\). Worked example: With a 2000 mAh battery, life = 2000/9.35 = 214 days (0.6 years). Reducing to 24 messages/day drops energy to 1.63 mAh/day, extending life to 1227 days (3.4 years). For 10+ year battery life, use a larger battery (6000 mAh) with infrequent reporting (6 messages/day = 0.43 mAh/day = 14,000 days).

  1. When would you choose Sigfox over LoRaWAN?
    • Use Sigfox when:
      • Simplest possible device design required
      • No infrastructure investment available
      • < 140 messages/day is sufficient
      • Small payloads (< 12 bytes)
      • Sigfox coverage exists in deployment area
    • Use LoRaWAN when:
      • Need larger payloads
      • Need more frequent messages
      • Want control over network infrastructure
      • Need lower latency
      • Data privacy is critical

50.3.3 Interactive: Battery Life Estimator

50.4 Python Implementations

50.4.1 Sigfox Message Capacity and Battery Life Calculator

This Python implementation calculates whether your application fits Sigfox constraints and estimates battery life:

#!/usr/bin/env python3
"""
Sigfox Deployment Analysis Tool
Calculates message constraints and battery life
"""

# Sigfox Constants
SIGFOX_MAX_PAYLOAD_BYTES = 12
SIGFOX_DAILY_UPLINK_LIMIT = 140
SIGFOX_DAILY_DOWNLINK_LIMIT = 4
SIGFOX_TX_CURRENT_MA = 40
SIGFOX_TX_DURATION_SEC = 6  # Includes triple transmission
SIGFOX_RX_CURRENT_MA = 50
SIGFOX_RX_DURATION_SEC = 25
SIGFOX_SLEEP_CURRENT_UA = 1

def analyze_sigfox_deployment(
    payload_bytes: int,
    messages_per_day: int,
    downlinks_per_day: int = 0,
    battery_mah: int = 2000
) -> dict:
    """
    Analyze Sigfox deployment feasibility and battery life.

    Args:
        payload_bytes: Size of each message payload
        messages_per_day: Number of uplink messages per day
        downlinks_per_day: Number of downlink messages per day
        battery_mah: Battery capacity in mAh

    Returns:
        Dictionary with analysis results
    """
    results = {
        'payload_valid': payload_bytes <= SIGFOX_MAX_PAYLOAD_BYTES,
        'uplink_valid': messages_per_day <= SIGFOX_DAILY_UPLINK_LIMIT,
        'downlink_valid': downlinks_per_day <= SIGFOX_DAILY_DOWNLINK_LIMIT,
        'overall_valid': True,
        'errors': []
    }

    # Validate constraints
    if not results['payload_valid']:
        results['errors'].append(
            f"Payload {payload_bytes} bytes exceeds {SIGFOX_MAX_PAYLOAD_BYTES} byte limit"
        )
        results['overall_valid'] = False

    if not results['uplink_valid']:
        results['errors'].append(
            f"Messages {messages_per_day}/day exceeds {SIGFOX_DAILY_UPLINK_LIMIT}/day limit"
        )
        results['overall_valid'] = False

    if not results['downlink_valid']:
        results['errors'].append(
            f"Downlinks {downlinks_per_day}/day exceeds {SIGFOX_DAILY_DOWNLINK_LIMIT}/day limit"
        )
        results['overall_valid'] = False

    # Calculate energy consumption
    tx_energy_per_msg = (SIGFOX_TX_CURRENT_MA * SIGFOX_TX_DURATION_SEC) / 3600  # mAh
    rx_energy_per_msg = (SIGFOX_RX_CURRENT_MA * SIGFOX_RX_DURATION_SEC) / 3600  # mAh

    daily_tx_energy = tx_energy_per_msg * messages_per_day
    daily_rx_energy = rx_energy_per_msg * downlinks_per_day

    # Sleep energy (24h minus active time)
    active_hours = (messages_per_day * SIGFOX_TX_DURATION_SEC +
                   downlinks_per_day * SIGFOX_RX_DURATION_SEC) / 3600
    sleep_hours = 24 - active_hours
    daily_sleep_energy = (SIGFOX_SLEEP_CURRENT_UA / 1000) * sleep_hours

    daily_total_energy = daily_tx_energy + daily_rx_energy + daily_sleep_energy

    # Battery life calculation
    battery_life_days = battery_mah / daily_total_energy if daily_total_energy > 0 else float('inf')
    battery_life_years = battery_life_days / 365

    results['energy'] = {
        'tx_per_message_mah': round(tx_energy_per_msg, 4),
        'rx_per_downlink_mah': round(rx_energy_per_msg, 4),
        'daily_tx_mah': round(daily_tx_energy, 4),
        'daily_rx_mah': round(daily_rx_energy, 4),
        'daily_sleep_mah': round(daily_sleep_energy, 4),
        'daily_total_mah': round(daily_total_energy, 4),
        'battery_life_days': round(battery_life_days, 1),
        'battery_life_years': round(battery_life_years, 1)
    }

    # Usage statistics
    results['usage'] = {
        'uplink_utilization': round(messages_per_day / SIGFOX_DAILY_UPLINK_LIMIT * 100, 1),
        'downlink_utilization': round(downlinks_per_day / SIGFOX_DAILY_DOWNLINK_LIMIT * 100, 1),
        'payload_utilization': round(payload_bytes / SIGFOX_MAX_PAYLOAD_BYTES * 100, 1),
        'messages_remaining': SIGFOX_DAILY_UPLINK_LIMIT - messages_per_day,
        'downlinks_remaining': SIGFOX_DAILY_DOWNLINK_LIMIT - downlinks_per_day,
        'bytes_remaining': SIGFOX_MAX_PAYLOAD_BYTES - payload_bytes
    }

    return results


def print_analysis(results: dict, scenario_name: str = ""):
    """Pretty print analysis results."""
    print("=" * 60)
    if scenario_name:
        print(f"Scenario: {scenario_name}")
        print("-" * 60)

    # Validation
    print("\nConstraint Validation:")
    status = "VALID" if results['overall_valid'] else "INVALID"
    print(f"  Overall: {status}")

    if results['errors']:
        for error in results['errors']:
            print(f"  ERROR: {error}")

    # Usage
    print("\nUsage Statistics:")
    print(f"  Uplink utilization:   {results['usage']['uplink_utilization']}%")
    print(f"  Downlink utilization: {results['usage']['downlink_utilization']}%")
    print(f"  Payload utilization:  {results['usage']['payload_utilization']}%")
    print(f"  Messages remaining:   {results['usage']['messages_remaining']}/day")
    print(f"  Bytes remaining:      {results['usage']['bytes_remaining']} bytes")

    # Energy
    print("\nEnergy Analysis:")
    print(f"  Daily TX energy:    {results['energy']['daily_tx_mah']} mAh")
    print(f"  Daily RX energy:    {results['energy']['daily_rx_mah']} mAh")
    print(f"  Daily sleep energy: {results['energy']['daily_sleep_mah']} mAh")
    print(f"  Daily total:        {results['energy']['daily_total_mah']} mAh")
    print(f"\n  Battery Life: {results['energy']['battery_life_years']} years ({results['energy']['battery_life_days']} days)")

    print("=" * 60)


# Example usage
if __name__ == "__main__":
    # Scenario 1: Environmental sensor (hourly readings)
    results1 = analyze_sigfox_deployment(
        payload_bytes=10,
        messages_per_day=24,
        downlinks_per_day=0,
        battery_mah=2000
    )
    print_analysis(results1, "Environmental Sensor (Hourly)")

    # Scenario 2: Asset tracker (every 15 minutes)
    results2 = analyze_sigfox_deployment(
        payload_bytes=12,
        messages_per_day=96,
        downlinks_per_day=1,
        battery_mah=3000
    )
    print_analysis(results2, "Asset Tracker (15-min intervals)")

    # Scenario 3: High-frequency monitoring (exceeds limit)
    results3 = analyze_sigfox_deployment(
        payload_bytes=8,
        messages_per_day=200,  # Exceeds 140 limit!
        downlinks_per_day=0,
        battery_mah=2000
    )
    print_analysis(results3, "High-Frequency (WILL FAIL)")

Example Output:

============================================================
Scenario: Environmental Sensor (Hourly)
------------------------------------------------------------

Constraint Validation:
  Overall: VALID

Usage Statistics:
  Uplink utilization:   17.1%
  Downlink utilization: 0.0%
  Payload utilization:  83.3%
  Messages remaining:   116/day
  Bytes remaining:      2 bytes

Energy Analysis:
  Daily TX energy:    1.6 mAh
  Daily RX energy:    0.0 mAh
  Daily sleep energy: 0.024 mAh
  Daily total:        1.624 mAh

  Battery Life: 3.4 years (1231 days)
============================================================

50.4.2 Sigfox Payload Encoder/Decoder

#!/usr/bin/env python3
"""
Sigfox Payload Encoder/Decoder
Demonstrates efficient encoding for 12-byte payloads
"""
import struct
from dataclasses import dataclass
from typing import Tuple

@dataclass
class EnvironmentalPayload:
    """Environmental sensor payload (12 bytes)"""
    temperature: float     # -327.67 to +327.67 C (0.01 resolution)
    humidity: float        # 0-100% (0.01 resolution)
    pressure: float        # 500-1155 hPa (0.01 resolution)
    battery: float         # 0-5.1V (0.02 resolution)
    flags: int             # 8 boolean flags packed into 1 byte

    @classmethod
    def encode(cls, temp: float, humidity: float, pressure: float,
               battery: float, flags: int) -> bytes:
        """Encode sensor data to 12-byte payload."""
        # Scale values to integers
        temp_int = int(temp * 100)          # 2 bytes, signed
        humidity_int = int(humidity * 100)  # 2 bytes, unsigned
        pressure_int = int((pressure - 500) * 100)  # 2 bytes, offset encoding
        battery_int = int(battery * 50)     # 1 byte (0-255 = 0-5.1V)

        # Pack into bytes (little-endian for consistency)
        payload = struct.pack('<hhHHBBxx',  # 12 bytes total
                              temp_int,
                              humidity_int,
                              pressure_int,
                              0,  # Reserved
                              battery_int,
                              flags)

        return payload[:12]  # Ensure exactly 12 bytes

    @classmethod
    def decode(cls, payload: bytes) -> 'EnvironmentalPayload':
        """Decode 12-byte payload to sensor data."""
        if len(payload) != 12:
            raise ValueError(f"Payload must be 12 bytes, got {len(payload)}")

        temp_int, humidity_int, pressure_int, _, battery_int, flags = \
            struct.unpack('<hhHHBBxx', payload)

        return cls(
            temperature=temp_int / 100,
            humidity=humidity_int / 100,
            pressure=(pressure_int / 100) + 500,
            battery=battery_int / 50,
            flags=flags
        )

    def to_hex(self) -> str:
        """Convert to hexadecimal string representation."""
        payload = self.encode(
            self.temperature, self.humidity,
            self.pressure, self.battery, self.flags
        )
        return payload.hex().upper()


@dataclass
class GPSPayload:
    """GPS tracker payload (12 bytes)"""
    latitude: float    # -90 to +90 degrees
    longitude: float   # -180 to +180 degrees
    speed: int         # 0-255 km/h
    heading: int       # 0-359 degrees (mapped to 0-255)
    battery: int       # 0-100%
    flags: int         # 8 boolean flags

    @classmethod
    def encode(cls, lat: float, lon: float, speed: int,
               heading: int, battery: int, flags: int) -> bytes:
        """Encode GPS data to 12-byte payload."""
        # Latitude: 3 bytes, ~1m resolution
        lat_int = int((lat + 90) * 93206)  # 0-180 deg -> 0-16777215 (24 bits)

        # Longitude: 3 bytes, ~1m resolution
        lon_int = int((lon + 180) * 46603)  # 0-360 deg -> 0-16777215 (24 bits)

        # Heading: map 0-360 to 0-255
        heading_byte = int(heading * 255 / 360)

        # Pack: 3 + 3 + 1 + 1 + 1 + 1 + 2(reserved) = 12 bytes
        payload = struct.pack('<3s3sBBBBxx',
                              lat_int.to_bytes(3, 'little'),
                              lon_int.to_bytes(3, 'little'),
                              min(speed, 255),
                              heading_byte,
                              min(battery, 100),
                              flags)

        return payload

    @classmethod
    def decode(cls, payload: bytes) -> 'GPSPayload':
        """Decode 12-byte payload to GPS data."""
        if len(payload) != 12:
            raise ValueError(f"Payload must be 12 bytes, got {len(payload)}")

        lat_bytes = payload[0:3]
        lon_bytes = payload[3:6]
        speed = payload[6]
        heading_byte = payload[7]
        battery = payload[8]
        flags = payload[9]

        lat_int = int.from_bytes(lat_bytes, 'little')
        lon_int = int.from_bytes(lon_bytes, 'little')

        return cls(
            latitude=(lat_int / 93206) - 90,
            longitude=(lon_int / 46603) - 180,
            speed=speed,
            heading=int(heading_byte * 360 / 255),
            battery=battery,
            flags=flags
        )


# Example usage
if __name__ == "__main__":
    print("=" * 50)
    print("Sigfox Payload Encoding Examples")
    print("=" * 50)

    # Environmental sensor example
    print("\n1. Environmental Sensor Payload:")
    env_payload = EnvironmentalPayload.encode(
        temp=22.5,
        humidity=65.0,
        pressure=1013.25,
        battery=3.7,
        flags=0b00000011  # Two flags set
    )
    print(f"   Hex: {env_payload.hex().upper()}")
    print(f"   Size: {len(env_payload)} bytes")

    # Decode and verify
    decoded = EnvironmentalPayload.decode(env_payload)
    print(f"   Decoded: T={decoded.temperature}C, H={decoded.humidity}%")

    # GPS tracker example
    print("\n2. GPS Tracker Payload:")
    gps_payload = GPSPayload.encode(
        lat=51.5074,
        lon=-0.1278,
        speed=45,
        heading=90,
        battery=78,
        flags=0b00000001
    )
    print(f"   Hex: {gps_payload.hex().upper()}")
    print(f"   Size: {len(gps_payload)} bytes")

    # Decode and verify
    gps_decoded = GPSPayload.decode(gps_payload)
    print(f"   Decoded: Lat={gps_decoded.latitude:.4f}, Lon={gps_decoded.longitude:.4f}")
    print(f"   Speed={gps_decoded.speed}km/h, Heading={gps_decoded.heading}deg")

50.5 Knowledge Check


The Challenge: You have only 4 downlinks per day per device. When should you use them?

Downlink Energy Cost:

Typical uplink-only transmission:
- TX: 40 mA for 6 seconds = 0.067 mAh
- Return to sleep immediately

Transmission WITH downlink request:
- TX: 40 mA for 6 seconds = 0.067 mAh
- RX window 1: Wait 20 seconds at 50 mA = 0.278 mAh
- RX window 2: Wait 20 seconds at 50 mA (if no RX1) = 0.278 mAh
- Total: 0.623 mAh (9.3× more energy than uplink-only!)

Battery impact:
- 24 uplinks/day: 1.6 mAh → 5.1 year battery life
- 4 downlinks/day: 2.5 mAh → 3.3 year battery life (35% reduction)

Decision Matrix: Use downlinks for:

TIER 1: Critical Configuration (Use downlinks)

  • ✓ Firmware update trigger (once per quarter = 0.003 downlinks/day average)
  • ✓ Emergency device shutdown (rare event)
  • ✓ Security credential rotation (once per month = 0.033 downlinks/day)
  • ✓ Sensor calibration after drift detected (once per 6 months)

TIER 2: Operational Changes (Maybe use downlinks)

  • ⚠ Sampling frequency adjustment (alternative: schedule in advance)
  • ⚠ Alert threshold changes (alternative: device stores multiple profiles, switch locally)
  • ⚠ Power mode changes (alternative: time-based schedules)

TIER 3: Regular Operations (DO NOT use downlinks)

  • ✗ ACK for every uplink (wasteful - trust triple redundancy)
  • ✗ Daily configuration sync (upload schedule once, store locally)
  • ✗ Periodic keep-alive (uplink provides keep-alive)
  • ✗ Data request/response patterns (design uplink-only architecture)

Architecture Patterns:

❌ BAD: Request-Response Pattern

Device: Sends temperature reading (uplink)
Server: Requests full diagnostic report (downlink)
Device: Sends diagnostic data (uplink)

Cost: 2 downlinks/day per device just for diagnostics
→ Uses 50% of daily downlink quota
→ No quota remaining for actual control

✓ GOOD: Scheduled Reporting

Device: Sends temperature + diagnostics in ONE message (uplink)
Server: Passive monitoring, only sends downlink when threshold breached
Device: Receives alert threshold update (downlink ~once per week)

Cost: 0.14 downlinks/day average
→ Uses 3.5% of daily quota
→ 96.5% quota available for emergencies

❌ BAD: Remote Control Pattern

Smart irrigation system with 100 zones:
- Each zone needs "water on/off" command daily
- 100 zones × 2 commands = 200 downlinks/day needed
- Sigfox limit: 4 downlinks/day per device

Result: IMPOSSIBLE with Sigfox

✓ GOOD: Autonomous Control with Cloud Override

Smart irrigation with local controller:
- Controller stores watering schedule locally (uploaded via downlink once per week)
- Device executes schedule autonomously
- Reports daily summary to cloud (uplink)
- Cloud can override schedule via downlink if weather changes

Downlinks needed: 0.14/day (weekly updates)
→ Saves 99.93% of downlink quota
→ Works within Sigfox limits

Downlink Budget Allocation Example (agricultural sensor):

Use Case Frequency Downlinks/Day Priority
Emergency shutdown 1 per year 0.003 Critical
Firmware update trigger 4 per year 0.011 Critical
Threshold adjustment 1 per month 0.033 High
Schedule update 1 per week 0.143 Medium
Manual diagnostic 1 per quarter 0.011 Low
TOTAL AVERAGE 0.201/day
Reserved for burst 3.8/day

Real-World Case Study:

Company: Smart building management (1,000 HVAC sensors)

Original Design (Failed):

  • Each sensor reports temperature every 10 minutes (140 msgs/day)
  • Server sends setpoint adjustment every hour (24 downlinks/day)
  • Problem: 24 > 4 downlink limit ✗

Redesign (Successful):

  • Sensor stores 4 daily setpoint schedules (morning, afternoon, evening, night)
  • Server uploads new schedule via downlink once per week (0.14 downlinks/day)
  • Sensor autonomously switches between profiles based on local time
  • Server can override via downlink in emergency (< 1 per day average)
  • Result: 0.14 + 1 = 1.14 downlinks/day average ✓

Battery Life Improvement:

Original design (would have needed Class C LoRaWAN):
- 24 downlinks/day × 0.556 mAh = 13.3 mAh/day
- Battery life: 2000 mAh / 13.3 = 150 days

Redesigned (Sigfox-compatible):
- 1.14 downlinks/day × 0.556 mAh = 0.63 mAh/day
- Plus 140 uplinks: 140 × 0.067 = 9.4 mAh/day
- Total: 10.0 mAh/day
- Battery life: 2000 mAh / 10.0 = 200 days (33% longer)

Key Takeaways:

  1. Downlinks are expensive: 9× more energy than uplinks due to RX windows
  2. Design uplink-only: Store intelligence in device, not cloud
  3. Schedule, don’t command: Upload schedules via rare downlinks, execute locally
  4. Reserve quota: Keep 3 of 4 daily downlinks unused for emergencies
  5. Batch updates: Send multiple configuration changes in one downlink (8-byte payload)

Golden Rule: If your application needs more than 1 downlink per day per device on average, redesign to reduce downlink dependency or choose LoRaWAN/NB-IoT instead.

50.6 Summary

In this hands-on chapter, you learned:

Wokwi Simulation:

  • How to encode sensor data into Sigfox’s 12-byte payload
  • Daily message budget management (140 messages/day)
  • Power consumption tracking and battery life estimation
  • Coverage simulation with RSSI indication

Python Implementations:

  • Sigfox deployment feasibility calculator
  • Energy consumption and battery life estimation
  • Efficient payload encoding/decoding strategies
  • Scaled integer encoding for compact data representation

Key Takeaways:

  • Sigfox’s 12-byte limit requires careful payload design
  • Use scaled integers instead of floats to maximize data density
  • Pack multiple booleans into bit flags
  • Always calculate battery life before deployment
  • Test coverage thoroughly before production rollout

Common Pitfalls

Skipping interactive AT command testing and going directly to firmware integration delays debugging when messages don’t appear in the backend. Always test the basic AT$SF send command manually first to confirm hardware, registration, and coverage before writing application code.

Development and testing consume real Sigfox messages from the 140/day budget. Using rapid-fire testing during lab development can exhaust the daily limit before real field testing. Use message rate limiting during development and count test transmissions carefully.

Evolving sensor requirements often change payload layout. Without a version byte in the payload, all deployed devices need simultaneous firmware updates when the format changes. Include a 4-bit or 1-byte version field to enable gradual payload format migration.

A single backend “message received” doesn’t confirm production-ready coverage. Verify that messages are received by at least 2 base stations to confirm diversity coverage needed for reliable production operation.

50.7 What’s Next

Complete your Sigfox learning:

Topic Link Description
Sigfox Assessment sigfox-assessment.html Comprehensive quizzes and knowledge review
LoRaWAN Hands-On lorawan-hands-on.html Compare with LoRaWAN simulation and coding exercises
LPWAN Comparison lpwan-comparison-and-review.html Side-by-side technology comparison across all LPWAN options