16 min read

GenServer

GenServers in Elixir are your reliable spellbinding companions in the world of concurrency and state management. Today, we'll unveil how they act as the heart of our IoT system, seamlessly integrating and orchestrating all our devices.
GenServer powering an IoT system

As we move forward in our path to build Nectar, we stand at a crossroads where we'll apply our knowledge from the previous chapter on Agents to streamline the implementation of light and camera managers. To maintain an engaging and efficient pace, we won't delve deep into the nitty-gritty of implementation. Still, we will instead present you with an API reference and provide links to the repository for those of you who wish to explore further. As we mentioned, the previous chapter gives you the foundation to easily understand the implementation of these two other managers. This will set the stage for the next phase of Nectar's development.

We are going to set foot in the space of GenServers, a fundamental concept in OTP programming, with roots in Erlang and seamlessly integrated into Elixir. GenServers are the workhorses of concurrent and fault-tolerant Elixir applications, managing state, enabling concurrency, and efficiently handling messages. These qualities make them the ideal choice for implementing our gateway, bridging the gap between device managers, the control panel, and future cloud services.

Let's begin our exploration of GenServers and get ready to build the Nectar Gateway, a cornerstone of our interconnected world. Before diving deeper, we'll take a moment to familiarize ourselves with the Lamp and Camera modules' APIs through some interactive iex experimentation. This hands-on experience will provide us with insights and context as we delve into the new concepts explained in this chapter.

API Overview

Before diving deep into GenServers, we'll have a brief section to introduce the Lamp and Camera modules, essential components of the Nectar system. Now that you have the background, you'll be better equipped to understand their APIs and how they fit into the whole system.

Lamp

The Nectar.Lamp.Agent module provides functionalities for managing and controlling smart lamps through the use of an Elixir Agent. It allows you to interact with lamp devices, control their state, set colors, adjust brightness, schedule actions, and retrieve schedules for specific days of the week.

Key Functions

  • start_link/0: Initializes a Lamp Agent, enabling lamp control. This function sets up an Agent process with an initial lamp state.
  • get_lamp_state/1: Retrieves the current state (on or off) of the lamp managed by the Agent. It queries the Agent's state and extracts the lamp's state.
  • turn_on/1: Turns the lamp on. It updates the Agent's state by invoking the Lamp.turn_on/1 function, effectively switching the lamp's state to on.
  • turn_off/1: Turns the lamp off. Similarly to turn_on/1, this function updates the Agent's state, turning the lamp off.
  • set_lamp_state/2: Sets the lamp's state explicitly to either on or off based on the on_off parameter. This function allows you to control the lamp's state directly.
  • toggle_lamp_state/1: Toggles the lamp's state between on and off. It uses the Lamp.toggle/1 function to change the lamp's state, providing a convenient way to switch it without specifying the desired state explicitly.
  • set_rgb_color/2: Sets the lamp's RGB color. This function updates the lamp's color by invokingLamp.set_rgb_color/2, allowing you to specify the desired color.
  • set_white_mode/2: Toggles white mode on or off for the lamp. It interacts with the Lamp.set_white_mode/2 function to set the lamp to white mode or disable it.
  • set_dim_percentage/2: Adjust the lamp's brightness by specifying a dimming percentage. It calls Lamp.set_dim_percentage/2 to control the lamp's brightness level.
  • set_schedule/4: Schedules actions for the lamp on specific days of the week. This function allows you to set up timed actions for the lamp, but please note that it may not have an actual implementation yet, as mentioned in the code comments.
  • get_day_schedule/2: Retrieves the scheduled actions for a particular day of the week. It queries the Agent's state to fetch the lamp's schedule for a given day of the week.

Note that some functions like set_schedule/4 are included for illustration and may not have an actual implementation, as they require more in-depth exploration for now.

Camera

The Nectar.Camera.Agent module manages camera-related operations within an Elixir application. It utilizes an Elixir Agent to encapsulate and control camera functionality, including capturing snapshots, managing video sequences, detecting motion, enabling night mode, and controlling pan-tilt-zoom (PTZ) functions.

Key Functions

  • start_link/0: Initializes a Camera Agent, allowing for camera control. This function sets up an Agent process with an initial camera state.
  • get_snapshot/1: Captures a snapshot from the camera. It retrieves the current camera state and invokes the Camera.get_snapshot/1 function to capture a snapshot.
  • start_recording/2: Initiates video recording on the camera for a specified duration in seconds. This function updates the camera state using Camera.start_recording/2 to start recording.
  • stop_recording/1: Stops the ongoing video recording on the camera. It changes the camera state to stop recording by invoking Camera.stop_recording/1.
  • get_video_sequence/2: Simulates a video sequence for a specified duration in seconds. This function retrieves the camera state and generates a video sequence using Camera.get_video_sequence/2. Please note that this function may not have a real implementation at this stage, as indicated in the comments.
  • set_motion_detected/2: Sets the motion detection state for the camera. It updates the camera's motion detection state based on the provided parameter using Camera.set_motion_detected/2.
  • set_night_mode/2: Toggles night mode on or off for the camera. This function controls the camera's night mode setting using Camera.set_night_mode/2.
  • set_ptz/2: Controls the pan, tilt, and zoom (PTZ) functions of the camera. It updates the camera's PTZ settings based on the provided {pan, tilt, zoom} tuple using Camera.set_ptz/2.

Similar to the Lamp module, it's important to note that some functions, like get_video_sequence/2, may not have a real implementation at this stage and are marked as such in the comments. This module provides a structured interface to manage camera operations, facilitating the control and interaction with camera devices within an Elixir application.

Commanding from iex

Lamp Module

# Start a Lamp Agent
iex(1)> {:ok, lamp_agent} = Nectar.Lamp.Agent.start_link()
{:ok, #PID<0.150.0>}

# Turn the lamp on
iex(2)> Nectar.Lamp.Agent.set_lamp_state(lamp_agent, true)
:ok

# Toggle the lamp state
iex(3)> Nectar.Lamp.Agent.toggle_lamp_state(lamp_agent)
:ok

# Set the lamp's RGB color
iex(4)> Nectar.Lamp.Agent.set_rgb_color(lamp_agent, {255, 0, 0})
:ok

# Adjust the lamp's brightness
iex(5)> Nectar.Lamp.Agent.set_dim_percentage(lamp_agent, 50)
:ok

# Schedule actions for Monday
iex(6)> Nectar.Lamp.Agent.set_schedule(lamp_agent, :monday, "08:00 AM", [action1: "turn_on", action2: "set_color"])
:ok

# Retrieve the schedule for Monday
iex(7)> Nectar.Lamp.Agent.get_day_schedule(lamp_agent, :monday)
[[action1: "turn_on", action2: "set_color"]]

Camera

# Start a Camera Agent
iex(1)> {:ok, camera_agent} = Nectar.Camera.Agent.start_link()
{:ok, #PID<0.150.0>}

# Capture a snapshot from the camera
iex(2)> Nectar.Camera.Agent.get_snapshot(camera_agent)
nil

# Enable motion detection
iex(3)> Nectar.Camera.Agent.set_motion_detected(camera_agent, true)
:ok

# Toggle night mode
iex(4)> Nectar.Camera.Agent.set_night_mode(camera_agent, true)
:ok

# Control PTZ (pan, tilt, zoom)
iex(5)> Nectar.Camera.Agent.set_ptz(camera_agent, {45.0, 30.0, 2.0})
:ok

These manual verifications allow you to interact with the Lamp and Camera modules, exploring their functionalities within the iex environment.

Please remember that the actual implementations of some functions may require further development and are intended for illustrative purposes.

GenServer

In this section, we'll delve into the concept of GenServers and their role within our IoT system. GenServers are a fundamental part of concurrent and fault-tolerant Elixir applications. They will serve as the backbone for our central coordination mechanism, the Nectar Gateway. Here's a brief introduction to GenServers:

  • What are GenServers?: GenServer, short for Generic Server, is a fundamental concept in Elixir and OTP (Open Telecom Platform) programming. They are specialized Elixir processes designed to encapsulate and manage state, facilitate concurrent operations, and efficiently handle asynchronous messages. GenServers form the building blocks of robust and concurrent systems, providing a structured framework for creating fault-tolerant and scalable applications.
  • Why GenServers?: GenServers represent the ideal foundation for Gateway, acting as a link between device managers (such as the PIR sensor, lamp, and camera), the control panel, and forthcoming cloud services. Their design and capabilities facilitate seamless communication and orchestration among these components, making them an ideal choice for our intelligent home automation system.

Main Features

Behavior Implementation

The GenServer behavior is a foundational building block in Elixir's OTP for creating concurrent and stateful processes. It defines a set of callback functions that need to be implemented by modules using the behavior. These callback functions include init/1, handle_call/3, handle_cast/2, and handle_info/2, among others. By implementing these callbacks, developers can define the behavior and logic of their processes, making it a versatile tool for handling various concurrency and state management scenarios.

Message Handling

One of the key features of a GenServer process is its ability to handle messages asynchronously. It can receive and process messages sent to it by other processes or external entities. Messages can be of different types and carry data payloads. The handle_call/3 function is used for synchronous message handling, while the handle_cast/2 and handle_info/2 functions are used for asynchronous message handling. This message-passing mechanism allows for communication between processes and coordination in concurrent systems.

State Management

GenServer processes can maintain and manage their internal state. The state is typically initialized in the init/1 function and can be modified and accessed throughout the lifetime of the process. This stateful nature is essential for processes that need to maintain context and data across multiple message-handling interactions. It enables processes to encapsulate their own data and maintain isolation from other processes, contributing to the reliability and robustness of concurrent applications.

Relevant Functions

handle_all/3

This function is used for synchronous message handling. When another process or external entity sends a message to the GenServer usingGenServer.call/3, this function is responsible for processing the message and providing a reply. It is associated with the GenServer.call/3 function, which allows sending synchronous requests and receiving responses. Implementing handle_call/3 involves pattern matching on the message, performing the necessary computation, and returning a response to the caller. This function is suitable for scenarios where a response is required before proceeding, making it essential for synchronous interactions.

handle_cast/2

This one is also used for asynchronous message handling within a GenServer. It enables the GenServer to receive and process messages without providing an immediate response. Messages sent to the GenServer using GenServer.cast/2 are handled by this function. Implementing handle_cast/2 involves pattern matching on the message, performing actions or computations, and potentially updating the GenServer's state. This function is ideal for scenarios where immediate responses are not necessary, such as logging, background tasks, or events that trigger actions.

handle_info/2

This function provides an asynchronous communication mechanism to the GenServe. It is used for processing system-level messages and timeouts that do not originate from GenServer.call/3 or GenServer.cast/2 calls. This function allows the GenServer to react to various events and timers. Implementing handle_info/2 involves pattern matching on the received message and taking appropriate actions, which may include updating the GenServer's state or triggering specific behaviors. This function is essential for handling timer-related events, system signals, and custom asynchronous messages that do not follow the request-response pattern.

Inter-Device Communication

When considering communication mechanisms, one might think of options such as REST over HTTP, gRPC, and GraphQL, each with its own strengths and use cases. However, working within the BEAM and OTP ecosystem offers a unique advantage. Here, we can harness platform primitives directly, avoiding the need for additional communication layers, learning new DSLs, and incurring extra latency, and other complexities.

Firstly, the BEAM virtual machine, upon which Elixir and Erlang are built, offers inherent advantages, including lightweight processes, fault tolerance, and high concurrency. These qualities align with our goal of building a robust and responsive IoT platform. This ecosystem ensures efficient, real-time communication between components while minimizing latency and enhancing fault tolerance.

Additionally, GenServers provide a direct and low-overhead mechanism for communication. They allow us to manage state and concurrency within a single process, eliminating the need for complex request-response cycles often associated with REST or the schema definition and code generation requirements of gRPC and GraphQL. This simplicity reduces the number of moving parts in our system, making it more manageable and reliable.

The GenServer running will communicate seamlessly with the Agents in other devices, such as the PIR sensor, lamp, and camera. This communication is facilitated by the inherent distribution and message-passing capabilities of the BEAM virtual machine. As a result, our architecture is highly scalable and capable of handling numerous devices and concurrent operations, ensuring a responsive and efficient IoT ecosystem.

While various communication mechanisms are available, we have chosen to implement our communication approach using the Erlang/BEAM interprocess communication model. This decision aligns with our overarching strategy of creating a dependable, fault-tolerant, and responsive IoT platform.

Behavior and Use Cases

Our goal is to understand how the Gateway, implemented as a GenServer, coordinates actions and responds to various events, setting the stage for a deeper dive into its architecture and our specific implementation. While we are leveraging the powerful features of Elixir's GenServer, our discussion primarily revolves around how we've tailored it to our needs. As a GenServer, it acts as the central controller within our system, receiving events from the PIR sensor, processing them, and triggering actions based on predefined logic. Additionally, it will serve as a bridge to cloud services, extending the reachability of its functions to the web and mobile devices.

The primary role of the Gateway is to coordinate actions among the PIR sensor, lamp, and camera. While there are numerous possible use cases, for this chapter, we'll focus on implementing a specific scenario: when the PIR sensor detects movement, it communicates with the Gateway. The Gateway responds by turning on the lamp for N minutes and capturing an N-second video snapshot. In the future, these rules may become user-configurable parameters, allowing users to define their IoT system's behavior according to their preferences. Some other possible use cases include handling sensor tamper detection, responding to dashboard signals for light control and color changes, taking pictures, and accommodating additional devices like smart switches, door locks, temperature and humidity sensors, sirens, and more.

The Gateway GenServer

We'll create the Nectar.Gateway GenServer module. We'll use use GenServer it to set up the basic structure, and we'll define the initial state in the init/1 callback.

Initialize the Gateway

defmodule Nectar.Gateway do
  use GenServer

  alias Nectar.Camera.Agent, as: CameraAgent
  alias Nectar.Lamp.Agent, as: LampAgent
  alias Nectar.PIRSensor.Agent, as: PIRSensorAgent

  @recording_time_seconds 15
  @valid_device_types [:pir_sensor, :camera, :lamp]

  def start_link(opts \\ %{}) do
    GenServer.start_link(__MODULE__, opts, name: :gateway)
  end

lib/nectar/gateway/gateway.ex

As in Agents, the start_link/0 function is used to initiate the creation of a new process for the Gateway GenServer. It takes optional parameters in the form of a keyword list (opts) and can assign a unique name (name) to the process if needed. It allows us to customize the server's behavior based on the provided options while ensuring each instance has its own distinct name, making it identifiable within the system.

  def init(_) do
    initial_state = %{
      pir_sensor: nil,
      lamp: nil,
      camera: nil
    }

    {:ok, initial_state}
  end

lib/nectar/gateway/gateway.ex

init/1 is a callback in an Elixir GenServer. It initializes the state of the Gateway GenServer by defining its base state, represented here as a map. In this case, the state includes placeholders for pir_sensor, lamp, andcamera, each initialized to nil. After the devices report themselves to the gateway, their respective PIDs will be stored in this state, allowing the Gateway to manage and communicate with these devices effectively throughout their lifecycle.

  def get_gateway_pid() do
    Process.whereis(:gateway)
  end

lib/nectar/gateway/gateway.ex

The get_gateway_pid/0 function allows us to retrieve the Process Identifier (PID) of the Gateway GenServer. In our current setup, all devices operate within the same virtual machine. While considering the eventual transition to a multi-node deployment for IoT, Elixir's flexibility makes such migration straightforward. At this stage of development, we've opted for a single-node setup to simplify the initial implementation, keeping in mind future distribution requirements.

  def get_device_pid(device_type) do
    gateway = get_gateway_pid()
    GenServer.call(gateway, {:get_device_pid, device_type})
  end

lib/nectar/gateway/gateway.ex

The get_device_pid/1 function retrieves the Process Identifier (PID) of a specific type of device within the system. These PIDs are obtained during a brief handshake process when the devices first attempt to communicate with the gateway. After successfully registering themselves with the gateway, their PIDs are stored in the gateway's state. The function takes a device_type parameter, indicating the type of device (e.g., :pir_sensor, :lamp, :camera) for which you want to obtain the PID. Internally, it locates the Gateway PID using the get_gateway_pid/0 function and sends a call to the Gateway to fetch the PID associated with the specified device type. This approach ensures access to device PIDs for subsequent interactions.

  def send_message(agent_pid, message) do
    GenServer.cast(__MODULE__, {:send_message, agent_pid, message})
  end

  def get_camera_agent_pid(state) do
    state.agents[CameraAgent]
  end

  def get_lamp_agent_pid(state) do
    state.agents[LampAgent]
  end

  def get_pir_sensor_agent_pid(state) do
    state.agents[PIRSensorAgent]
  end

lib/nectar/gateway/gateway.ex

  • send_message(agent_pid, message): We can send a message to an agent process specified by agent_pid using this function that utilizes the GenServer.cast/2 to send the message as a non-blocking operation. GenServer enables asynchronous communication between the gateway and agent processes here.
  • get_camera_agent_pid(state), get_lamp_agent_pid(state), get_pir_sensor_agent_pid(state): These ones retrieve the PIDs of specific agents (camera, lamp, or PIR sensor) from the state parameter. The state typically holds the PIDs of various agents and devices, allowing easy access to the respective agent's PID for communication and control purposes.

Implement Device Communication: We'll add basic functions to handle communication with the PIR sensor, lamp, and camera

 def handle_pir_event(gateway_pid, lamp_pid, camera_pid) do
    IO.puts("PIR sensor detected movement")

    LampAgent.turn_on(lamp_pid)
    CameraAgent.start_recording(camera_pid, @recording_time_seconds)

    Process.send_after(gateway_pid, {:turn_off_lamp, lamp_pid}, @recording_time_seconds * 1000)
    Process.send_after(gateway_pid, {:get_video_sequence, camera_pid}, @recording_time_seconds * 1000)

    {:noreply, gateway_pid}
  end

lib/nectar/gateway/gateway.ex

  • handle_pir_event(gateway_pid, lamp_pid, camera_pid): Here, we handle the event triggered by the PIR (Passive Infrared) sensor detecting movement. After logging a message indicating that movement has been detected we instruct the LampAgent to turn on the lamp using LampAgent.turn_on/1, and also start camera recording through CameraAgent.start_recording/2. Additionally, we schedule two future actions: turning off the lamp and retrieving the video sequence. These actions are scheduled using Process.send_after/4, which sends messages to itself (gateway_pid) after a specified delay in milliseconds, ensuring that the lamp is turned off and the video sequence is fetched after a predetermined recording time.
  def handle_info({:turn_off_lamp, lamp_pid}, state) do
    IO.puts("Turning off the lamp")
    LampAgent.turn_off(lamp_pid)

    {:noreply, state}
  end

  def handle_info({:get_video_sequence, camera_pid}, state) do
    IO.puts("Retrieving video sequence")

    case CameraAgent.get_video_sequence(camera_pid, @recording_time_seconds) do
      {:ok, video_data} ->
        store_video_sequence(video_data)
        IO.puts("Video sequence stored in a mock storage location.")

      {:error, reason} ->
        IO.puts("Failed to retrieve video sequence: #{reason}")
    end

    {:noreply, state}
  end

# ...

  defp store_video_sequence(video_data) do
    # Actual storage logic will be implemented here.
    sample = binary_part(video_data, 0, 12)
    hex_strings = for <<byte::size(8) <- sample>>, do: Integer.to_string(byte, 10)
    formatted_hex = "<<" <> Enum.join(hex_strings, ", ") <> "...>>"
    IO.puts("Video sequence stored (Hexadecimal): #{formatted_hex}")
    :ok
  end

lib/nectar/gateway/gateway.ex

  • handle_info({:turn_off_lamp, lamp_pid}, state): This function is responsible for handling a message that instructs it to turn off the lamp. It logs a message indicating the lamp's status change and then uses LampAgent.turn_off/1 to turn off the lamp controlled by the LampAgent.
  • handle_info({:get_video_sequence, camera_pid}, state): is responsible for handling a message that triggers the retrieval of a video sequence from a camera agent. By using CameraAgent.get_video_sequence/2 it tries to fetch video data from the camera specified by camera_pid. Depending on the result, it either stores the video sequence using a placeholder function store_video_sequence/1 and logs a message about the storage or logs an error message if the retrieval fails. Like the previous function, it returns {:noreply, state} to indicate that it has processed the message.
  • defp store_video_sequence(video_data): This is a private function used internally by the handle_info/2 function above. It is a placeholder for the actual implementation of video sequence persistence logic. The video_data parameter represents the video sequence obtained from the camera. This is a placeholder implementation, for now, it prints a simplified representation of the video data in hexadecimal format to simulate the storage process. It returns :ok to indicate successful execution. In the actual implementation, this function would be replaced with code to store the video data in a suitable storage medium or location.

Device Managers Registration

  def handle_cast({:register_device, message = %{type: _device_type, pid: device_pid}}, state) do
    if message.type in @valid_device_types && is_pid(device_pid) do
      new_state = Map.put(state, message.type, device_pid)
      {:noreply, new_state}
    else
      {:noreply, state}
    end
  end

lib/nectar/gateway/gateway.ex

  • handle_cast({:register_device, message = %{type: _device_type, pid: device_pid}}, state): is used by device managers to register themselves with the gateway after activation. When a device manager is initialized, one of its first actions is to contact the gateway and provide its process identifier (PID) along with its device type. This information is bundled in the message parameter, a map containing the type and pid keys.
    • message.type: Specifies the type of the device, such as "pir_sensor," "camera," or "lamp."
    • message.pid: Represents the PID of the device manager process.

The function first checks if the provided device_pid is a valid process identifier and if the message.type is one of the predefined valid device types stored in the @valid_device_types module attribute. If both conditions are met, indicating that the device manager is legitimate and properly activated, the function updates state by associating the device_pid with its corresponding device type. This information is stored in an internal map, allowing the gateway to keep track of device managers and their PIDs.

In summary, this function serves as the entry point for device managers to register themselves with the gateway, enabling subsequent communication between the gateway and devices within the network. The registration process ensures that the gateway maintains an up-to-date mapping of device types to their respective PIDs, facilitating the delivery of messages and commands to the appropriate devices when events occur in the system.

In the provided code snippet below, we're focusing on the PIR Sensor device manager, but similar principles apply to understanding the Lamp and Camera device managers. Let's break down the key elements:

In the Lamp device manager module we start by defining a constant, @device_type, to specify the device type, which is set to :lamp. When initializing the device manager (Agent) using start_link/0, we create a new Lamp process and immediately invoke the handshake/1 function, passing the device manager's PID to it.

defmodule Nectar.PIRSensor.Agent do
  alias Nectar.PIRSensor
  import Nectar.DeviceInfo

  @device_type :pir_sensor

  # ...

  def start_link() do
    with {:ok, pid} <- Agent.start_link(fn -> PIRSensor.new() end) do
      handshake(pid)
      {:ok, pid}
    end
  end

  defp handshake(pid) do
    handshake(@device_type, pid)
  end
  
  # ...
end

lib/nectar/pir_sensor/agent.ex

handshake/1 function, defined in the same agent's module, serves as a bridge to invoke a generic handshake/2 function defined in DeviceInfo. This generic function in our IoT system's communication leverages three functions:

defmodule Nectar.DeviceInfo.DeviceInfo do
  # ...

  def gateway_pid() do
    case Process.whereis(:gateway) do
      nil -> :error
      pid when is_pid(pid) -> pid
    end
  end

  def handshake(device_type, device_pid) when is_atom(device_type) and is_pid(device_pid) do
    send_to_gateway(:register_device, %{type: device_type, pid: device_pid})
  end

  def send_to_gateway(message_type, message) when is_atom(message_type) do
    pid = gateway_pid()
    GenServer.cast(pid, {message_type, message})
  end
  # ...
end

lib/nectar/device_info/device_info.ex

Briefly,

  • gateway_pid/0: Retrieves the PID of the Gateway GenServer, enabling other modules to locate and communicate with it.
  • handshake/2: Allows device managers to register themselves with the Gateway by sending their device type and PID.
  • send_to_gateway/2: Sends messages to the Gateway GenServer, facilitating asynchronous communication between device managers and the central Gateway for reporting status and events.

Wrapping Up

We are closing our initial exploration of the Gateway, which plays a central role in coordinating actions and managing events within our system. Designed as a GenServer, it harnesses Elixir's message-passing capabilities to receive and process messages asynchronously. The current implementation does not cover many corner cases and certain details still require a robust implementation, but we have taken the first step to acquire the foundational knowledge that will allow us to extend the functionality of our system. In future discussions, we'll dive deeper into the architecture and mechanics of this crucial component, shedding light on its inner workings and how it interacts with other parts of our IoT ecosystem.

References