I’m looking into Elixir because I think it can help us make better embedded (IoT) devices.
One of the things I’m working on is a KNX-stack. KNX is an open standard for commercial and domestic building automation. [wikipedia]
KNX can communicate connectionless and connection-oriented.
The former case is simple. A frame is received by the hardware and is piped through the layers which are just pure functions
# main-pipe
frame |> layer0 |> layer1 |> layer2 |> layer3 |> layer4 |> application
Each layer decodes some bits (which is really great with binary-pattern-matching) and on the way a %Frame{}
struct is built. If a frame should not be further processed by the pipe (eg not addressed), a drop
-flag is set in the struct, the following layers match on this flag an just pass the frame on until the end of the pipe.
The pain starts with the connection-oriented frames. These have to be processed by a state-machine (SM) in layer2. When layer2 receives a connection-oriented frame it sets the frame.drop
flag (so that the pipe can continue) and builds an event for the SM from the frame and dispatches it to the SM-GenServer.
Depending on the frame and the state of the SM one of twelve actions is to be performed. An action is a pure function that returns a struct:
- new state (always)
- an up-frame (optional, eg a data frame for an open connection is passed to the upper layer)
- down-frame (optional, eg an ACK for a data frame)
- timer-config (optional, eg reset the timeout when a data frame arrives)
- defer-event (optional, the frame can’t be handled now)
def dispatch(%Event{} = event) do
GenServer.cast(@me, {:dispatch, event})
end
def handle_cast({:dispatch, %Event{} = event}, %State{} = state) do
state
|> handle_dispatch(event)
|> noreply
end
def handle_dispatch(%State{} = state, %Event{} = event) do
{new_state, action} = state.handler.(state, event)
{new_state, up_frame, dn_frame, timer_config, defer} = action.(new_state, event)
# these are just pipes similar to the 'main-pipe' (see above)
send_frame(:up, up_frame) # layer1 |> layer0 |> hardware
send_frame(:dn, dn_frame) # layer3 |> layer4 |> application
new_state
end
So the complexity suddenly explodes here, instead of a simple linear flow layer2 suddenly may shoot frames in different directions, or, even worse may just send a frame because a timeout occurs (eg a CLOSE-frame).
The thing works, but its hard to test and I don’t like it. Any ideas how to improve it?
One thing I considered was to pipe the complete state with the frame. Everything inside the pipe could be pure funtions (even the SM). On the end of the pipe I would not have a single frame but maybe multiple frames and maybe some commands for a separate GenServer controlling the timeouts.
for example for an incoming connection-oriented data-frame for an exisiting connection I would get:
- the decoded frame as a
%Frame{}
-struct - the request to send an ACK-frame back down
- instructions to reset the connection-timeout-timer.
But that would mean a lot of data on the pipe…