2013-11-17

Building A Sensor Phalanx With PHP

Sensors are fun. They report from the physical world into the digital. But getting the signal into php is only the first part, you will have to get them out again. This post shows how to get data from analog sensors pushed to the browser. It uses Carica Chip, if you haven't read my previous blog post you should do it first.

Carica Io implements its own event loop, but if it finds ReactPHP installed, it will base it on this implementation, making it compatible and allowing to use the libraries. One of the libraries for ReactPHP is Ratchet, a websocket server implementation.

In this example two servers are used. An http server for file delivery (html and javascript for the UI) and a websocket server that pushes the sensor values to the browser. Smootie Charts displays the data in the browser as a running line chart, not unlike an oscilloscope.

Source

The latest source for this example can be found on Bitbucket:
https://bitbucket.org/ThomasWeinert/carica-sensor-phalanx

Project Setup

Clone Carica Chip Skeleton to create a new project and add Ratchet to the dependencies.

composer create-project carica/chip-skeleton SensorPhalanx \
  --stability=dev 

cd SensorPhalanx

composer require cboden/ratchet

copy dist.configuration.php configuration.php

Edit configure.php to match your hardware setup.

The HTTP Server

In the LED example, the http server had only one route to deliver the html file. Now we need to deliver the javascript, too. An additional route allows to deliver static files from a subdirectory.

$route = new Http\Route();
$route->match('/', new Http\Route\File('index.html'));
$route->startsWith('/files', new Http\Route\Directory(__DIR__));
$httpServer = new Http\Server($route);
$httpServer->listen(8080);

The Sensor Phalanx

The Sensor Phalanx object encapsulates the Ratchet websocket server and provides the callbacks for it. It needs an array of sensors. Port 8080 is already used for the http server, so it should listen on port 8081. An interval triggers the update of the clients.

// create the phalanx with some sensors
include(__DIR__.'/class/SensorPhalanx.php');
$phalanx = new Carica\SensorPhalanx(
  [
    'lightsensor' => new Chip\Sensor\Analog($board->pins[15]),
    'potentiometer' => new Chip\Sensor\Analog($board->pins[16])
  ]
);
// tell the websocket server to listen
$phalanx->listen(8081);

// update clients connected to the phalanx with the current sensor data
$loop->setInterval(
  function () use ($phalanx) {
    $phalanx->update();
  },
  200
);

The SensorPhalanx Class

The SensorPhalanx class needs to implement two interfaces. Carica\Io\Event\HasLoop defines access to the Carica Io event loop. It can be implemented using the trait Carica\Io\Event\Loop\Aggregation.

Ratchet\MessageComponentInterface defines the callback methods for the websocket server.
  • onOpen() - add a client
  • onClose() - remove a client
  • onMessage() - handle data recieved from the browser
  • onError() - log/handle errors
public function onOpen(Ratchet\ConnectionInterface $connection) {
  $this->_clients[spl_object_hash($connection)] = $connection;
}

public function onClose(Ratchet\ConnectionInterface $connection) {
  unset($this->_clients[spl_object_hash($connection)]);
}

public function onMessage(Ratchet\ConnectionInterface $connection, $message) {
}

public function onerror(Ratchet\ConnectionInterface $connection, \Exception $e) {
  echo "Error: ", $e->getMessage(), "\n";
  $connection->close();
}

Create And Listen

The constructor of the class just validates the sensor objects, and stores them. Listen creates the needed subobjects. Unlike the examples on the Ratchet website, the factory methods can not be used. They would create a new event loop. The script already has one, used for the Arduino board and the http server. The $this->loop() method returns the Carica Io event loop and in a second step allows access to the actual ReactPHP event loop.

public function __construct(array $sensors) {
  foreach ($sensors as $index => $sensor) {
    if ($sensor instanceOf Chip\Sensor\Analog) {
      $this->_sensors[$index] = $sensor;
    }
  }
}

public function listen($port = 8081) {
  $socket = new \React\Socket\Server($this->loop()->loop());
  $socket->listen($port);
  $this->_server = new Ratchet\Server\IoServer(
    new Ratchet\Http\HttpServer(
      new Ratchet\WebSocket\WsServer(
        $this
      )
    ),
    $socket,
    $this->loop()->loop()
  );
}

Update

Update collects the data from all sensors into an array, encodes it to json and sends it to all connected clients.

public function update($log = TRUE) {
  $values = ['type' => 'sensors'];
  foreach ($this->_sensors as $index => $sensor) {
    $values['sensors'][$index] = $sensor->get();
  }
  $json = json_encode($values);
  if ($log) {
    echo $json, "\n";
  }
  foreach ($this->_clients as $client) {
    $client->send($json);
  }
}

User Interface

The html file uses Smoothie Charts. It defines some CSS and a canvas for the chart. The chart is created and connected to the canvas. A list of colors provides a different color for each line.

The websocket server connects and gets a callback. If a message is received it loops over the sensor values. If a line for the index already exists the value is appended. For an unknown index a new line is created and the assigned color is removed from the internal list.

var smoothie = new SmoothieChart(
  {
    maxValue : 1,
    minValue: 0
  }
);
smoothie.streamTo(document.getElementById('monitorCanvas'), 250);

var colors = ['#00FF00', '#FF0000', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
var lines = [];

websocket = new WebSocket('ws://localhost:8081/');
websocket.onmessage = function(event) {
  var data = JSON.parse(event.data);
  if (data && data.type == 'sensors') {
    for (index in data.sensors) {
      if (typeof lines[index] == 'undefined') {
        var color = colors.shift();
        if (!color) {
          color = '#FFFFFF';
        }
        lines[index] = new TimeSeries();
        smoothie.addTimeSeries(
          lines[index],
          {
            lineWidth: 2,
            strokeStyle: color
          }
        );
      }
      lines[index].append(new Date().getTime(), data.sensors[index]);
    }
  }
};

In Action