How to Implement Firmware Updates (FUOTA) over HTTPS

FUOTA over HTTPS

Remote firmware updates are a cornerstone of scalable IoT. This guide shows you how to build a complete Firmware Update Over The Air (FUOTA) flow using TagoIO Files, Analysis, and a Dashboard Template.

Note: This tutorial covers the HTTPS method, where the device downloads the file via a direct URL. If your devices require updates over MQTT (chunking), please refer to our How to Implement Firmware Updates (FUOTA) over MQTT.

The Architecture

  1. User Action: An operator uploads a .bin file via a pre-built Blueprint Dashboard.

  2. Storage: The dashboard automatically saves the file to TagoIO Files and triggers an Analysis.

  3. Secure Automation: The Analysis (running on Deno) updates the device’s Configuration Parameters, setting the status to sent: false (Pending).

  4. Device Logic: The device polls for pending parameters, downloads the file, and marks the parameter as sent: true (Acknowledged).


Step 1: Create the Analysis Script (Deno)

We will use Deno for this script. We also use the Resources class, which allows the script to run using its own specific token rather than your master Account Token.

  1. Go to Analysis in the sidebar.

  2. Create a new analysis.

  3. Set the Runtime to Deno.

  4. Paste the code below. You do not need to set any Environment Variables.

/*
 * Analysis: FUOTA Handler (HTTPS) with Cleanup & Validation
 * Runtime: Deno
 * Triggered by: Action (Variable: firmware_file)
 */
import { Analysis, Resources, Utils } from "npm:@tago-io/sdk";
import { DateTime } from "npm:luxon";

type validation_type = "success" | "danger" | "warning" | string;

interface IValidateOptions {
  show_markdown?: boolean;
  user_id?: string;
  session_id?: string;
}

/**
 * Helper: Sends validation feedback to the Input Form widget.
 */
function initializeValidation(resources: Resources, validationVariable: string, device_id: string, opts?: IValidateOptions) {
  let i = 0;
  return async function _(message: string, type: validation_type = "success") {
    if (!message || !type) throw "Missing message or type";

    i += 1;
    // 1. Clear old validation messages
    await resources.devices
      .deleteDeviceData(device_id, { variables: validationVariable, qty: 999 })
      .catch(console.log);

    // 2. Insert the new validation message
    await resources.devices
      .sendDeviceData(device_id, {
        variable: validationVariable,
        value: message,
        time: DateTime.now().plus({ milliseconds: i * 200 }).toJSDate(), 
        metadata: {
          type: ["success", "danger", "warning"].includes(type) ? type : null,
          color: !["success", "danger", "warning"].includes(type) ? type : undefined,
          show_markdown: !!opts?.show_markdown,
          user_id: opts?.user_id,
          session_id: opts?.session_id,
        },
      })
      .catch(console.error);

    return message;
  };
}

async function startAnalysis(context: any, scope: any) {
  context.log("Starting FUOTA Analysis...");
  const resources = new Resources({ token: context.token });

  // 1. Identify the device
  // Since we use an Action, scope[0] contains the triggering data (firmware_file)
  const deviceId = scope[0]?.device;
  if (!deviceId) {
    return context.log("Error: Analysis triggered without a device context.");
  }

  // 2. Setup Validation
  // If triggered by UI, we try to get the user ID for targeted feedback
  const environment = Utils.envToJson(context.environment);
  const userId = environment._user_id; 
  const validate = initializeValidation(resources, "firmware_validation", deviceId, { user_id: userId });

  try {
    const fileVar = scope.find((x: any) => x.variable === "firmware_file");
    const versionVar = scope.find((x: any) => x.variable === "firmware_version");

    // Check A: Missing Fields
    if (!fileVar || !versionVar) {
      throw await validate("Missing file or version. Please use the Input Form.", "danger");
    }

    // Check B: File Extension
    const filename = String(fileVar.value || "");
    if (!filename.toLowerCase().endsWith(".bin")) {
       throw await validate(`Invalid file: ${filename}. Please upload a .bin file.`, "danger");
    }

    // Check C: File URL
    const fileUrl = fileVar.metadata?.file?.url;
    if (!fileUrl) {
      throw await validate("File URL not found. Check Input Form settings.", "danger");
    }

    const newVersion = versionVar.value;
    context.log(`Deploying Firmware v${newVersion} to device ${deviceId}`);
    await validate(`Initializing update v${newVersion}...`, "warning");

    // =========================================================
    // CLEANUP STEP: Remove old parameters
    // =========================================================
    const currentParams = await resources.devices.paramList(deviceId);
    const keysToRemove = ["firmware_url", "target_version"];
    
    const deletePromises = currentParams
        .filter((p) => keysToRemove.includes(p.key))
        .map((p) => resources.devices.paramRemove(deviceId, p.id));
    
    if (deletePromises.length > 0) {
        await Promise.all(deletePromises);
        context.log(`Cleaned up ${deletePromises.length} old parameters.`);
    }

    // =========================================================
    // UPDATE STEP: Set new parameters
    // =========================================================
    const params = [
      { key: "firmware_url", value: fileUrl, sent: false },
      { key: "target_version", value: String(newVersion), sent: false },
    ];

    await resources.devices.paramSet(deviceId, params);

    // Success Message
    await validate(`Update v${newVersion} ready. Waiting for device...`, "success");
    context.log("Configuration parameters updated.");

  } catch (error) {
    if (typeof error !== "string") {
      context.log(error);
      await validate("Internal Error. Check analysis logs.", "danger");
    }
  }
}

Analysis.use(startAnalysis);


Step 2: Configure Access Management (IAM)

The Analysis script needs permission to read the file URL from the device parameters and send the data chunks back.

  1. Go to Access Management in the sidebar.

  2. Click Create Policy.

    • Name: “Analysis FUOTA Access”.

    • Target: Analysis.

    • Field: ID (You can select “Any” or target your specific analysis after Step 3).

  3. Permissions:

    • Permission 1:

      • Effect: Allow.
      • Resource: Device.
      • Rules: Check Access, Edit, Send Data, and Delete Data.
      • Target: Tag (You can select “Any” or target your specific device by adding a shared tag)
    • Permission 2:

      • Effect: Allow.
      • Resource: File.
      • Rules: Check Access.
      • Target: Any
  4. Click Create Policy.

Note: Without this policy, your script will fail with “Permission Denied” errors when trying to send the chunks.


Step 3: Install the Dashboard Template

To make setup faster, use our ready-to-use template instead of building widgets manually.

  1. Click here to install the FUOTA HTTPS Dashboard Template.

  2. Follow the prompts to install the dashboard into your account.

    1. Add a tag that is shared between your FUOTA devices to select them as the blueprint device.

The template is pre-configured to upload files to the system (“Send to resources”) and pass the correct variables to your script.


Step 4: Configure the Action

Instead of hiding the logic inside the dashboard widget, we will create an explicit rule.

  1. Go to Actions in the sidebar.

  2. Click Add Action.

  3. General:

    • Name: “FUOTA Handler (HTTPS)”

    • Type: Variable Trigger

    • Action: Run Analysis (Select the Deno script you created).

  4. Trigger:

    • Variable: firmware_file

    • Condition: Any value (implies “whenever data is sent”).

  5. Click Save.


Step 5: Device Logic (Python Simulation)

We use a Python script to simulate the client-side logic. This demonstrates the flow you will implement on your hardware (C++, Rust, etc.).

The Logic:
The device polls TagoIO for parameters where sent isfalse. This means “Pending.” If found, it downloads the file and then updates the parameter to sent: true to confirm receipt.

API References for Developers

When implementing this on your actual hardware, refer to these documentation endpoints:

Simulator Code (Python):

import requests
import time

# --- DEVICE CONFIGURATION ---
# Replace with the Device Token of the device selected in your Blueprint
DEVICE_TOKEN = "YOUR_DEVICE_TOKEN" 
API_URL = "https://api.tago.io/device/params"

headers = {
    "Authorization": DEVICE_TOKEN,
    "Content-Type": "application/json"
}

def check_for_updates():
    print("Checking for firmware updates...")
    
    # 1. Request only parameters that are PENDING (sent=false)
    # Ref: https://docs.tago.io/en/articles/14-device-api#configuration-parameters
    try:
        response = requests.get(f"{API_URL}?sent_status=false", headers=headers)
        if response.status_code != 200:
            print(f"Connection Error: {response.text}")
            return
            
        params = response.json().get("result", [])
    except Exception as e:
        print(f"Network Error: {e}")
        return

    # 2. Parse parameters
    firmware_url = None
    target_version = None
    param_ids = [] 

    for param in params:
        if param['key'] == 'firmware_url':
            firmware_url = param['value']
            param_ids.append(param['id'])
        if param['key'] == 'target_version':
            target_version = param['value']
            param_ids.append(param['id'])

    # 3. Decision Logic
    if firmware_url and target_version:
        print(f"--> Update Found! Target Version: {target_version}")
        perform_update(firmware_url, param_ids)
    else:
        print("--> System Up-to-Date.")

def perform_update(url, param_ids):
    print(f"Downloading firmware from: {url}")
    
    try:
        # 4. Download File
        file_resp = requests.get(url)
        
        # Simulate writing to flash memory
        filename = "update_package.bin"
        with open(filename, "wb") as f:
            f.write(file_resp.content)
            
        print(f"Firmware saved to local storage as '{filename}'")
        print("Verifying integrity... OK")
        print("Installing... OK")
        
        # 5. Acknowledge Update (Mark as Read/Sent)
        # We update 'sent: true' to tell TagoIO we received the data.
        # Ref: https://docs.tago.io/en/articles/14-device-api#edit-parameter
        for pid in param_ids:
             requests.put(f"{API_URL}/{pid}", headers=headers, json={"sent": True})
        
        print("Update confirmed to cloud. Rebooting device...")
        
    except Exception as e:
        print(f"Update Failed: {e}")

# Simulate a device loop
if __name__ == "__main__":
    while True:
        check_for_updates()
        # Sleep for 10 seconds before checking again
        time.sleep(10)

How to Test

  1. Run the Simulator: Start the Python script locally. It will print “System Up-to-Date”.

  2. Use the Dashboard:

    • Open your new Blueprint Dashboard.

    • Select your simulator device from the selector.

    • Enter a version string (e.g., 2.1.0).

    • Upload a dummy .bin file.

    • Click Submit.

  3. Watch the Result:

    • The Python script will detect the update within 10 seconds.

    • It will download the file and print Update confirmed to cloud.

    • In TagoIO, if you check the device’s Configuration Parameters tab:

      • Before the device picks it up, the row’s sent configuration is not highlighted (sent: false).

      • After the script runs, the row’s sent configuration is highlighted (sent: true).