Behind "Avian Visitors": How to Build Real-Time IoT Event Pipelines with Elixir, Nerves, and WebSockets

Hey everyone, Alex here from Coding with Alex. If you’ve been browsing the Hacker News front page today, you might have spotted a delightfully intriguing title sitting near the top: "Avian Visitors". On the surface, it looks like a simple, charming hobbyist project tracking birds visiting a backyard feeder. But as developers, we know that behind every seemingly simple "hobby" project lies a rabbit hole of fascinating engineering challenges.

When you peer under the feathers of a modern smart-feeder or wildlife tracking system, you find a masterclass in edge computing, real-time data ingestion, and low-latency event broadcasting. How do you take a hardware sensor (like a camera or motion detector), process the event at the resource-constrained edge, stream it to the cloud, run ML classification, and push it to thousands of web browsers in real-time—all without melting your server or your wallet?

Today, we are going to build our own production-grade blueprint inspired by "Avian Visitors". We’ll design an end-to-end event pipeline using Elixir, Nerves (for the edge device), and Phoenix LiveView. Why this stack? Because Elixir’s BEAM virtual machine is practically cheat-code status when it comes to concurrency, low latency, and fault-tolerant IoT connections. Let's dive in!

The Architecture: From Edge to Browser

To build a robust system that can handle real-time avian detection, we need to divide our architecture into three distinct layers:

  • The Edge Device (The Nest): A Raspberry Pi Zero 2 W running Elixir Nerves. It interfaces with an infrared motion sensor (PIR) and a camera module.
  • The Cloud Ingestion Hub (The Canopy): A Phoenix server acting as a highly concurrent WebSocket listener that ingests telemetry, manages state, and dispatches events.
  • The Live Dashboard (The Birdwatch): A Phoenix LiveView frontend that receives server-sent events over WebSockets and renders updates instantly to users without a single line of client-side JavaScript framework boilerplate.

Here is how the data flows through our system:

[ PIR Sensor Trip ] 
       │
       ▼
[ Raspberry Pi (Nerves) ] ──(Secure WebSockets)──► [ Phoenix Cloud App ]
                                                           │
                                            ┌──────────────┴──────────────┐
                                            ▼                             ▼
                                    [ Phoenix.PubSub ]             [ Image Storage / ML ]
                                            │
                                            ▼
                                   [ LiveView Client ] 
                                 (Real-time DOM Update)

Part 1: The Edge Device with Elixir Nerves

If you haven't used Nerves before, it's a game-changer for embedded systems. Instead of installing a heavy Linux distribution like Raspbian, Nerves compiles your Elixir code down to a minimal, bootable Linux micro-kernel (often under 30MB) that boots in seconds straight into the Erlang VM.

Handling GPIO Interrupts on the Edge

We’ll use the circuits_gpio library to listen to our hardware sensor. Here is how we write a GenServer in Elixir to monitor our motion sensor and send a trigger to our cloud server when a bird lands.

defmodule AvianVisitor.Edge.MotionSensor do
  use GenServer
  require Logger
  alias Circuits.GPIO

  @pir_pin 17 # GPIO pin connected to the PIR sensor
  @cooldown_ms 5000 # Prevent spamming alerts

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(_opts) do
    {:ok, pin} = GPIO.open(@pir_pin, :input)
    # Configure interrupt to fire on rising edge (0 to 1 transition)
    :ok = GPIO.set_interrupts(pin, :rising)
    
    {:ok, %{pin: pin, last_trigger: 0}}
  end

  @impl true
  def handle_info({:circuits_gpio, @pir_pin, _timestamp, 1}, state) do
    now = System.monotonic_time(:millisecond)

    if now - state.last_trigger > @cooldown_ms do
      Logger.info("Avian visitor detected! Alerting the cloud...")
      notify_cloud_server()
      {:noreply, %{state | last_trigger: now}}
    else
      # Ignore triggers during the cooldown period
      {:noreply, state}
    end
  end

  defp notify_cloud_server do
    # Dispatches an asynchronous message to our WebSocket connection process
    send(AvianVisitor.Edge.SocketClient, :trigger_event)
  end
end

This simple GenServer leverages hardware-level interrupts. Instead of burning CPU cycles looping and checking if a pin is high, the BEAM sleeps the process until the kernel notifies it of a voltage change. This is incredibly power-efficient for remote, battery-powered IoT installations.

Part 2: Ingesting at Scale with Phoenix WebSockets

On the cloud side, we need a backend that can handle thousands of these edge devices streaming data simultaneously. Phoenix handles WebSockets out of the box using Erlang's lightweight processes. Each connected edge device gets its own isolated process (taking up only a few kilobytes of RAM).

Defining the Custom Socket Channel

Let's create a Channel on our Phoenix server to handle incoming connections from our avian sensors. This channel will authenticate the device, parse incoming events, and broadcast them to a local PubSub topic.

defmodule AvianVisitorWeb.DeviceChannel do
  use AvianVisitorWeb, :channel
  require Logger

  # "devices:device_id"
  def join("devices:" <> device_id, auth_payload, socket) do
    if authorized?(device_id, auth_payload) do
      {:ok, assign(socket, :device_id, device_id)}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

  def handle_in("motion_detected", %{"timestamp" => ts}, socket) do
    device_id = socket.assigns.device_id
    Logger.info("Received motion signal from device: #{device_id}")

    # Broadcast to the internal web dashboard subscribers
    Phoenix.PubSub.broadcast(
      AvianVisitor.PubSub,
      "dashboard:activity",
      {:new_visitor, %{device_id: device_id, timestamp: ts}}
    )

    {:reply, :ok, socket}
  end

  defp authorized?(_device_id, %{"token" => token}) do
    # In a real-world app, verify against your DB or a JWT secret
    token == "super-secure-edge-token-123"
  end
end

Because Elixir's PubSub is decentralized and distributed, if we scale our servers horizontally across multiple nodes, these events are automatically routed across our cluster using Distributed Erlang PG2/PG groups. We don't even need a Redis container for pub/sub messaging!

Part 3: The Real-Time Frontend with Phoenix LiveView

Now for the magic. We want a live dashboard that updates instantly when a bird lands on our feeder. Historically, this meant writing a React/Vue frontend, setting up a Redux store, writing a WebSocket handler in JavaScript, managing state sync, and dealing with CORS.

With Phoenix LiveView, we write 100% server-side Elixir code. LiveView manages the persistent WebSocket connection to the browser, tracks state changes, diffs the HTML on the server, and pushes raw, tiny binary diffs over the wire to update the DOM automatically.

Writing the LiveView Component

defmodule AvianVisitorWeb.DashboardLive do
  use AvianVisitorWeb, :live_view
  alias Phoenix.PubSub

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket) do
      # Subscribe to the channel we broadcasted to in our Socket Channel
      PubSub.subscribe(AvianVisitor.PubSub, "dashboard:activity")
    end

    # Initial state with an empty list of visitors
    {:ok, assign(socket, visitors: [])}
  end

  @impl true
  def render(assigns) do
    ~H"""
    

Live Avian Visitors Dashboard

Live Monitoring Active
<%= if Enum.empty?(@visitors) do %>

Waiting for feather-clad friends to arrive...

<% else %>
<%= for visitor <- @visitors do %>
🐦

Visitor on Feeder #<%= visitor.device_id %>

Arrived at: <%= format_time(visitor.timestamp) %>

<% end %>
<% end %>
""" end @impl true def handle_info({:new_visitor, visitor_data}, socket) do # Prepend the new visitor to our list and keep the latest 5 records updated_visitors = [visitor_data | socket.assigns.visitors] |> Enum.take(5) # LiveView automatically recalculates the DOM diff and pushes it to the browser {:noreply, assign(socket, visitors: updated_visitors)} end defp format_time(ts) do DateTime.from_unix!(ts, :second) |> Calendar.strftime("%I:%M:%S %p") end end

Think about what just happened here: when the hardware sensor on our Raspberry Pi fired, it sent a message over a WebSocket to our Phoenix cluster. The cluster broadcasted that message internally via PubSub. Our LiveView component received the message, updated its local state, calculated the exact HTML difference, and sent those bytes down to the user's browser. The user sees a new card animate into view in under 50 milliseconds—all with zero custom client-side JavaScript.

Security & Reliability Considerations at the Edge

Building real-time IoT networks isn't just about the happy path. If you are deploying edge devices out in the wild (literally!), you have to account for dirty power, spotty Wi-Fi, and security threats.

1. Resilience via Erlang Supervision Trees

If your backyard Wi-Fi drops, your WebSocket connection will fail. In many languages, this requires writing complex try-catch-reconnect loops. In Elixir, we let it crash. By placing our hardware connection processes under a Supervision Tree, the BEAM will automatically tear down the failed socket process and attempt to reconnect with an exponential backoff strategy.

2. End-to-End Encryption

Even though we aren't talking about TLS infrastructure setup today, remember that your edge devices should communicate over secure WebSockets (wss://). Ensure your Nerves firmware bundle includes a read-only root certificate store so it can securely validate your cloud server's domain.

Conclusion: The Power of Unified Stacks

Projects like "Avian Visitors" remind us why we fell in love with coding in the first place: the joy of connecting physical-world events to digital interfaces in real-time. By utilizing Elixir, Nerves, and Phoenix, we can build a highly performant, resilient system that spans from the copper wires of a GPIO pin all the way to a modern web browser, using a single, cohesive language.

The next time you are building an IoT project, an internal real-time monitoring tool, or a collaborative SaaS product, step away from the heavy, fragmented JS-framework setups. Give Elixir and Phoenix a try—you might be shocked at how fast you can build, scale, and ship.

Over to you: Have you experimented with edge computing, or run Elixir on embedded devices? What hardware setup are you using for your own home-automation or tracking projects? Let me know in the comments below!

Post a Comment

Previous Post Next Post