Packages

Scenic.Sensor - Sensor Pub/Sub Cache

Current section

Files

Jump to
scenic_sensor lib scenic_sensor.ex
Raw

lib/scenic_sensor.ex

#
# Created by Boyd Multerer on August 20, 2018.
# Copyright © 2018 Kry10 Industries. All rights reserved.
#
# Centralized sensor data pub-sub with cache
defmodule Scenic.Sensor do
use GenServer
@moduledoc """
A combination pub/sub server and data cache for sensors. It is intended to be the interface between
sensors (or other data sources) and Scenic scenes, although it has no dependencies on Scenic and can
be used in other applications.
## Installation
`Scenic.Sensor` can be installed by adding `:scenic_sensor` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:scenic_sensor, "~> 0.7.0"}
]
end
```
## Startup
In order to use `Scenic.Sensor`, you must first add it to your supervision tree. It should be ordered in the tree so that it has a chance to initialize before other processes start making calls to it.
def start(_type, _args) do
import Supervisor.Spec, warn: false
opts = [strategy: :one_for_one, name: ScenicExample]
children = [
{Scenic.Sensor, nil},
...
]
Supervisor.start_link(children, strategy: :one_for_one)
end
## Registering Sensors
Before a process can start publishing data from a sensor, it must register that sensor with `Scenic.Sensor`. This both prevents other processes from stepping on that data and alerts any subscribing processes that the sensor is coming online.
Scenic.Sensor.register( :sensor_id, version, description )
The `:sensor_id` parameter must be an atom that names the sensor. Subscribers will look for data from this sensor through that id.
The `version` and `description` paramters are bitstrings that describe this sensor. `Scenic.Sensor` itself does not process these values, but passes them to the listeners when the sensor comes online or when the sensors are listed.
Sensors can also unregister if they are no longer available.
Scenic.Sensor.unregister( :sensor_id )
Simply exiting the sensor does also cleans up its registration.
## Publishing Data
When a sensor process publishes data, two things happen. First, that data is cached in an `:ets` table so that future requests for that data from scenes happen quickly and don't need to bother the sensor. Second, any processes that have subscribed to that sensor are sent a message containing the new data.
Scenic.Sensor.publish( :sensor_id, sensor_value )
The `:sensor_id` parameter must be an atom that was previously registered by calling process.
The `sensor_value` parameter can be anything that makes sense for the sensor.
## Subscribing to a sensor
Scenes (or any other process) can subscribe to a sensor. They will receive messages when the sensor updates its data, comes online, or goes away.
Scenic.Sensor.subscribe( :sensor_id )
The `:sensor_id` parameter is the atom registered for the sensor.
The subscribing process with then start receiving messages that can be handled with `handle_info/2`
event | message
--- | ---
data | `{:sensor, :data, {:sensor_id, data, timestamp}}`
registered | `{:sensor, :registered, {:sensor_id, version, description}}`
unregistered | `{:sensor, :unregistered, :sensor_id}`
Scenes can also unsubscribe if they are no longer interested in updates.
Scenic.Sensor.unsubscribe( :sensor_id )
"""
# ets table names
@sensor_table __MODULE__
@name __MODULE__
# ============================================================================
# client api
# --------------------------------------------------------
@doc """
Retrieve the cached data for a named sensor.
This data is pulled from an `:ets` table and does not put load on the sensor itself.
## Parameters
* `sensor_id` an atom that is registered to a sensor.
## Return Value
{:ok, {sensor_id, data, timestamp}}
* `sensor_id` is the atom representing the sensor.
* `data` is whatever data the sensor last published.
* `timestamp` is the time - from `:os.system_time(:micro_seconds)` - the last data was published.
If the sensor is either not registered, or has not yet published any data, get returns
{:error, :no_data}
"""
@spec get(sensor_id :: atom) :: {:ok, any} | {:error, :no_data}
def get(sensor_id) when is_atom(sensor_id) do
case :ets.lookup(@sensor_table, sensor_id) do
[data] ->
{:ok, data}
# no data
_ ->
{:error, :no_data}
end
end
# --------------------------------------------------------
@doc """
List the registered sensors.
## Return Value
`list/0` returns a list of registered sensors
[{sensor_id, version, description, pid}]
* `sensor_id` is the atom representing the sensor.
* `version` is the version string supplied by the sensor during registration.
* `description` is the description string supplied by the sensor during registration.
* `pid` is the pid of the sensor process.
"""
@spec list() :: list
def list() do
:ets.match(@sensor_table, {{:registration, :"$1"}, :"$2", :"$3", :"$4"})
|> Enum.map(fn [key, ver, des, pid] -> {key, ver, des, pid} end)
end
# --------------------------------------------------------
@doc """
Publish a data point from a sensor.
When a sensor uses `publish/2` to publish data, that data is recorded in the
cache and a
{:sensor, :data, {:sensor_id, data, timestamp}}
message is sent to each subsciber. The timestamp is the current time in microsecods as returned
from `:os.system_time(:micro_seconds)`.
## Parameters
* `sensor_id` an atom that is registered to a sensor.
* `data` the data to publish.
## Return Value
On success, returns `:ok`
It returns `{:error, :not_registered}` if the caller is not the
registered process for the sensor.
"""
@spec publish(sensor_id :: atom, data :: any) :: :ok
def publish(sensor_id, data) when is_atom(sensor_id) do
timestamp = :os.system_time(:micro_seconds)
pid = self()
# enforce that this is coming from the registered sensor pid
case :ets.lookup(@sensor_table, {:registration, sensor_id}) do
[{_, _, _, ^pid}] ->
send(@name, {:put_data, sensor_id, data, timestamp})
:ok
# no data
_ ->
{:error, :not_registered}
end
end
# --------------------------------------------------------
@doc """
Subscribe the calling process to receive events about a sensor.
The events the caller will start receiving about a sensor are:
event | message
--- | ---
data | `{:sensor, :data, {:sensor_id, data, timestamp}}`
registered | `{:sensor, :registered, {:sensor_id, version, description}}`
unregistered | `{:sensor, :unregistered, :sensor_id}`
## Parameters
* `sensor_id` an atom that is registered to a sensor.
## Return Value
On success, returns `:ok`
"""
@spec subscribe(sensor_id :: atom) :: :ok
def subscribe(sensor_id) when is_atom(sensor_id) do
GenServer.call(@name, {:subscribe, sensor_id, self()})
end
# --------------------------------------------------------
@doc """
Unsubscribe the calling process from receive events about a sensor.
The caller will stop receiving events about a sensor
## Parameters
* `sensor_id` an atom that is registered to a sensor.
## Return Value
Returns `:ok`
"""
@spec unsubscribe(sensor_id :: atom) :: :ok
def unsubscribe(sensor_id) when is_atom(sensor_id) do
send(@name, {:unsubscribe, sensor_id, self()})
:ok
end
# --------------------------------------------------------
@doc """
Register the calling process as a data source for a sensor.
## Parameters
* `sensor_id` the sensor it being registered.
* `version` the sensor version string.
* `description` a sensor description string.
## Return Value
On success, returns `:ok`
If `sensor_id` is already registered to another process, it returns
{:error, :already_registered}
"""
@spec register(
sensor_id :: atom,
version :: String.t(),
description :: String.t()
) :: :ok
def register(sensor_id, version, description)
when is_atom(sensor_id) and is_bitstring(version) and is_bitstring(description) do
GenServer.call(
@name,
{:register, sensor_id, version, description, self()}
)
end
# --------------------------------------------------------
@doc """
Unregister the calling process as a data source for a sensor.
## Parameters
* `sensor_id` the sensor it being registered.
## Return Value
Returns `:ok`
"""
@spec unregister(sensor_id :: atom) :: :ok
def unregister(sensor_id) when is_atom(sensor_id) do
send(@name, {:unregister, sensor_id, self()})
:ok
end
# ============================================================================
# internal api
# --------------------------------------------------------
@doc false
def start_link(_) do
GenServer.start_link(__MODULE__, :ok, name: @name)
end
# --------------------------------------------------------
@doc false
def init(:ok) do
# set up the initial state
state = %{
data_table_id: :ets.new(@sensor_table, [:named_table]),
subs_id: %{},
subs_pid: %{}
}
# trap exits so we don't just crash when a subscriber goes away
Process.flag(:trap_exit, true)
{:ok, state}
end
# ============================================================================
# --------------------------------------------------------
# a sensor (or whatever) is putting data
@doc false
# the client api enforced the pid check
# yes, you could get around that by sending this message directly
# not best-practice, but is an escape valve.
# timestamp should be from :os.system_time(:micro_seconds)
def handle_info({:put_data, sensor_id, data, timestamp}, state) do
:ets.insert(@sensor_table, {sensor_id, data, timestamp})
send_subs(sensor_id, :data, {sensor_id, data, timestamp}, state)
{:noreply, state}
end
# --------------------------------------------------------
@doc false
def handle_info({:unsubscribe, sensor_id, pid}, state) do
{:noreply, unsubscribe(pid, sensor_id, state)}
end
# ============================================================================
# handle linked processes going down
# --------------------------------------------------------
def handle_info({:EXIT, pid, _reason}, state) do
# unsubscribe everything this pid was listening to
state = do_unsubscribe(pid, :all, state)
# if this pid was registered as a sensor, unregister it
:ets.match(@sensor_table, {{:registration, :"$1"}, :_, :_, pid})
|> Enum.each(fn [id] -> do_unregister(id, pid, state) end)
{:noreply, state}
end
# --------------------------------------------------------
@doc false
def handle_info({:unregister, sensor_id, pid}, state) do
do_unregister(sensor_id, pid, state)
{:noreply, state}
end
# ============================================================================
# CALLs - mostly for postive confirmation of sign-up style things
# --------------------------------------------------------
@doc false
def handle_call({:subscribe, sensor_id, pid}, _from, state) do
{reply, state} = do_subscribe(pid, sensor_id, state)
# send the already-set value if one is set
case get(sensor_id) do
{:ok, data} -> send_msg(pid, :data, data)
_ -> :ok
end
{:reply, reply, state}
end
# --------------------------------------------------------
@doc false
# handle sensor registration
def handle_call({:register, sensor_id, version, description, pid}, _from, state) do
key = {:registration, sensor_id}
{reply, state} =
case :ets.lookup(@sensor_table, key) do
# registered to pid - ok to change
[{_, _, _, ^pid}] ->
do_register(pid, sensor_id, version, description, state)
# previously crashed
[{_, _, _, nil}] ->
do_register(pid, sensor_id, version, description, state)
# registered to other. fail
[_] ->
{{:error, :already_registered}, state}
[] ->
do_register(pid, sensor_id, version, description, state)
end
{:reply, reply, state}
end
# ============================================================================
# handle sensor registrations
# --------------------------------------------------------
defp do_register(pid, sensor_id, version, description, state) do
key = {:registration, sensor_id}
:ets.insert(@sensor_table, {key, version, description, pid})
# link the sensor
Process.link(pid)
# alert the subscribers
send_subs(sensor_id, :registered, {sensor_id, version, description}, state)
# reply is sent back to the sensor
{{:ok, sensor_id}, state}
end
# --------------------------------------------------------
defp do_unregister(sensor_id, pid, state) do
reg_key = {:registration, sensor_id}
# first, get the registration and confirm this pid is registered
case :ets.lookup(@sensor_table, reg_key) do
[{_, _, _, ^pid}] ->
# alert the subscribers
send_subs(sensor_id, :unregistered, sensor_id, state)
# delete the table entries
:ets.delete(@sensor_table, reg_key)
:ets.delete(@sensor_table, sensor_id)
unlink_pid(pid, state)
:ok
# no registered. do nothing
_ ->
:ok
end
end
# ============================================================================
# handle client subscriptions
# --------------------------------------------------------
@spec do_subscribe(pid :: GenServer.server(), sensor_id :: atom, state :: map) :: any
defp do_subscribe(pid, sensor_id, %{subs_id: subs_id, subs_pid: subs_pid} = state) do
# record the subscription
subs_id =
Map.put(
subs_id,
sensor_id,
[pid | Map.get(subs_id, sensor_id, [])] |> Enum.uniq()
)
subs_pid =
Map.put(
subs_pid,
pid,
[sensor_id | Map.get(subs_pid, pid, [])] |> Enum.uniq()
)
# make sure the subscriber is linked
Process.link(pid)
{:ok, %{state | subs_id: subs_id, subs_pid: subs_pid}}
end
# --------------------------------------------------------
@spec do_unsubscribe(pid :: GenServer.server(), sensor_id :: atom, state :: map) :: any
defp do_unsubscribe(pid, :all, %{subs_pid: subs_pid} = state) do
Map.get(subs_pid, pid, [])
|> Enum.reduce(state, &unsubscribe(pid, &1, &2))
end
# --------------------------------------------------------
defp unsubscribe(pid, sensor_id, %{subs_id: subs_id, subs_pid: subs_pid} = state) do
# clean up the subs for a given sensor_id
subs_by_id =
Map.get(subs_id, sensor_id, [])
|> Enum.reject(fn sub_pid -> sub_pid == pid end)
subs_id = Map.put(subs_id, sensor_id, subs_by_id)
# part two
subs_by_pid =
Map.get(subs_pid, pid, [])
|> Enum.reject(fn sub_id -> sub_id == sensor_id end)
subs_pid = Map.put(subs_pid, pid, subs_by_pid)
state = %{state | subs_id: subs_id, subs_pid: subs_pid}
# if pid no longer subscribed to anything, then some further cleanup
state =
case subs_by_pid do
[] ->
{_, state} = pop_in(state, [:subs_pid, pid])
state
_ ->
state
end
# if sensor has no subscribers, then some further cleanup
state =
case subs_by_id do
[] ->
{_, state} = pop_in(state, [:subs_id, sensor_id])
state
_ ->
state
end
# does the right thing. only unlinks if no longer subscribing
# to anything and is not a sensor
unlink_pid(pid, state)
state
end
# --------------------------------------------------------
@spec send_subs(sensor_id :: atom, verb :: atom, msg :: any, state :: map) :: any
defp send_subs(sensor_id, verb, msg, %{subs_id: subs_id}) do
Map.get(subs_id, sensor_id, [])
|> Enum.each(&send_msg(&1, verb, msg))
end
# --------------------------------------------------------
@spec send_msg(pid :: GenServer.server(), verb :: atom, msg :: any) :: any
defp send_msg(pid, verb, msg) do
send(pid, {:sensor, verb, msg})
end
# --------------------------------------------------------
# only unlink a pid if it is not a registered sensor AND it
# has no subscriptions. return the state
defp unlink_pid(pid, %{subs_pid: subs_pid}) do
no_subs =
case subs_pid[pid] do
nil -> true
[] -> true
_ -> false
end
not_sensor =
case :ets.match(@sensor_table, {{:registration, :"$1"}, :_, :_, pid}) do
[] -> true
_ -> false
end
if no_subs && not_sensor do
Process.unlink(pid)
end
end
end