The major impetus for creating this software was that there were a number of unaddressable concerns with the third party backend we were utilizing with Optimize in order to pull BACnet and other communication protocol's data into our system. One of these concerns was that if there was a "stale" piece of data, or an unresponsive controller device, we wouldn't know it until a month later when we saw a long trendlog of the same value for a particular object. In order to correct this one thing, an entire redesign including a C-based open-sourced library that enabled me to have more finite control of the information flow was necessary.
While I was using Vuejs for real-time data messaging client-side, this time I was using a Websocket (using Redis, Socket.io, and Nodejs) to do the communicating rather than Event Source. Similarly, since we were no longer using our previous backend software, I was now gathering this information via a makeshift API that I created to interact with the command line toolkit mentioned previously. So now, anytime I needed to update a value, I could simply write
$object->readProperty(85);
85 being the BACnet array value for "present_value."
One of the big advantages of using our new system is that I could design completely independent System Status Boards for each location. This allowed the client to view data with updates at least every five minutes, but in most cases it was pushed via the BACnet's Subscription COV server update. This was injested by running the BACnet server process in Symfony's Process Component. Basically it ran in the background, and read each line every two seconds. All new information was sent over to be updated in the database, and Laravel was set up to run an update job that sent the websocket update anytime the model was updated.
Likewise, when I came across jobs that wouldn't run (basically the device wasn't responding), I had to exclude them from future read-property requests or it would really gum up the job update queues. And, unfortunately for me, this was before Laravel Horizon was out, so I was stuck with just viewing keys in Redis as quickly as possible. So, anytime a value failed to update, I made the device "offline," and changed all values to "N/A" to make it obvious to the client's engineers that something was amiss with that physical piece of equipment, or the connection to it.
I had to pore over some C code and change the output of the Subscription COV event in the BACnet stack library and recompile, which I was NOT comfortable doing! But it worked out. This allowed us to get those subscription events pushed from the controller device instead of just manually asking for all updates.
After about a year we realized that the BACnet Stack we were using was slowing us down, and the desired amount of updates per second just didn't cut it for a commercial product. We began talks with SoftDel which didn't pan out, and were in the midst of striking a deal with Polarsoft, one of the original BACnet vendors, when AOG ran into some money issues. I was really excited to replace the current BACnet stack with different command line solution that would handle most of the controller updates, and communicate in JSON rather than STDIN/STDOUT.
At our peak, we had this system installed in about fifteen locations, with some of the more notable locations being Fiesta Henderson Casino, Green Valley Ranch, Town Square, Sam's Town, and the Aliante Casino. I personally configured every server with Ubuntu 16.04 LTS, configured the web server (NGINX) for use with Laravel, and provided the custom networking setup that each different IT department required in order to give remote access to the system. There were HVAC techs on our team, but I was the only developer for this project.
<?php
namespace App;
use App\Jobs\ReadPropertyAndUpdate;
use App\Jobs\ReadPropertyMultipleAndUpdate;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use App\Device;
use App\Object;
use Symfony\Component\Process\Process;
class Bacnet {
public static function readObjectProperty( Object $object, $property, $index = null ) {
$instance = $object->device_instance;
$identifier = $object->identifier;
$type = $object->numeric_type;
if ( $index != null ) {
$statement = "bacrp $instance $type $identifier $property $index";
} else {
$statement = "bacrp $instance $type $identifier $property";
}
$command = config( 'optimize.bacnet_stack' ) . $statement;
$output = run_command( $command );
$value = preg_replace( '/[\x00-\x1F\x7F]/', '', $output );
if ( strpos( $value, 'BACnet Error:' ) > -1 ) {
$output = strstr( $value, 'BACnet Error:' );
$output = stristr( $output, 'Error Output:', true );
return $output;
}
$value = str_replace( [ '"' ], [ '' ], $value );
if ( strpos( $value, '{' ) > -1 ) {
$value = str_replace( [ '{', '}' ], '', $value );
$output = explode( ',', $value );
return $output;
}
return $value;
}
public static function writeObjectProperty( Object $object, $value, $property, $tag, $priority = 8, $index = -1 ) {
$instance = $object->device_instance;
$identifier = $object->identifier;
$type = $object->numeric_type;
$statement = "bacwp $instance $type $identifier $property $priority $index $tag $value";
$command = config( 'optimize.bacnet_stack' ) . $statement;
$output = run_command( $command );
if ( str_contains(strtolower($output),"writeproperty acknowledged" )) {
return true;
} else {
return false;
}
}
public static function readDeviceProperty( Device $device, $property, $index = '') {
$instance = $device->instance;
$statement = "bacrp $instance 8 $instance $property $index";
$command = config( 'optimize.bacnet_stack' ) . $statement;
$output = run_command( $command );
$value = preg_replace( '/[\x00-\x1F\x7F]/', '', $output );
if ( strpos( $value, 'BACnet Error:' ) > -1 ) {
$output = strstr( $value, 'BACnet Error:' );
$output = stristr( $output, 'Error Output:', true );
return $output;
}
$value = str_replace( [ '"' ], [ '' ], $value );
if ( strpos( $value, '{' ) > -1 ) {
$value = str_replace( [ '{', '}' ], '', $value );
$output = explode( ',', $value );
return $output;
}
return $value;
}
public static function updateObjectsWithReadProperty( Collection $collection ) {
$collection = $collection->unique();
$offlineGroup = collect();
$onlineGroup = collect();
// Make sure the objects are on "online" devices
foreach ( $collection as $object ) {
if ( $object->device->status == "online" ) {
$onlineGroup->push( $object );
dump('Online object: ' . $object->shorthand . ' - ' . $object->device->status);
} else {
$offlineGroup->push( $object );
dump('Offline object: ' . $object->shorthand . ' - ' . $object->device->status);
}
}
// dump('Online: ' . $onlineGroup->count(), 'Offline: ' . $offlineGroup->count());
foreach ( $onlineGroup as $object ) {
$job = ( new ReadPropertyAndUpdate( $object, false ) )->onQueue( 'medium' );
dispatch( $job );
}
}
public static function updateObjectValues( Collection $collection, $subscribeCheck = false, $queue = 'medium' ) {
// Declare our collections! We need some for Read Property and some for Read Property Multiple.
$rpGroup = collect();
$rpmGroup = collect();
// You might ask yourself "why use GroupBy once and then again later?" Because
// it's faster than evaluating each object by itself. Time tests were conducted.
$groups = $collection->groupBy( 'device_instance' );
foreach ( $groups as $key => $group ) {
// assemble the different groups of RP and RPM
$device = Device::where( 'instance', $key )->first();
if($device instanceof Device) {
if ( $device->supportsService( 'Read-Property-Multiple' ) ) {
$rpmGroup = $rpmGroup->merge( $group );
} elseif ( !$device->supportsService( 'Read-Property-Multiple' ) ) {
$rpGroup = $rpGroup->merge( $group );
}
}
}
// Now we separate our RPMs into separate groups (again!)
// because RPM can only be run once per device instance.
// we also have to CHUNK those requests by the capability
// of the device (APDU)
$rpmGroup = $rpmGroup->groupBy( 'device_instance' );
foreach ( $rpmGroup as $device_instance => $group ) {
$job = ( new ReadPropertyMultipleAndUpdate( $group, $device_instance, $subscribeCheck ) )->onQueue( $queue);
dispatch( $job );
}
// Alright! Now we can get to our Read Properties. Yeesh!
foreach ( $rpGroup as $object ) {
$job = ( new ReadPropertyAndUpdate( $object, $subscribeCheck ) )->onQueue( $queue);
dispatch( $job );
}
}
public static function runServer( $delay = 5, $instance = '' ) {
exec( "pkill bacserv" );
$process = new Process( config( 'optimize.bacnet_stack' ) . "bacserv $instance", null, null, null, null );
$process->start();
while ( $process->isRunning() ) {
$block = $process->getIncrementalErrorOutput();
if ( strpos( $block, "\n" ) > -1 ) {
$arrays = explode( "\n", $block );
$uniques = array_unique( $arrays );
foreach ( $uniques as $json ) {
$array = json_decode( $json, true );
if ( gettype( $array ) == 'array' ) {
if ( $array[ 'event' ] == 'ucov' ) {
$object
= Object::where( 'device_instance', $array[ 'instance' ] )->where( 'type', $array[ 'type' ] )->where( 'identifier', $array[ 'identifier' ] )->get();
if ( !$object->isEmpty() ) {
$object = $object->first();
if ( strpos( $object->type, 'multi' ) > -1 ) {
$stateText = $object->readProperty( 110 );
$object->update( [ 'present_value' => $stateText[ $array[ 'enumerated' ] - 1 ] ] );
} else if ( strpos( $object->type, 'binary' ) > -1 ) {
$binaryText = [
0 => 'inactive',
1 => 'active'
];
$object->update( [ 'present_value' => $binaryText[ $array[ 'enumerated' ] ] ] );
} else {
$object->update( [ 'present_value' => $array[ 'real' ] ] );
}
}
}
}
}
}
sleep( $delay );
}
}
public static function unconfirmedCovSub( $object ) {
/*
/home/vagrant/Sites/bacnet-stack-0.8.3/bin/bacscov 200 0 2 1 unconfirmed
Sent SubscribeCOV request. Waiting up to 9 seconds....
SubscribeCOV Acknowledged!
RP: Sending Ack!
UCOV: Received Notification!
UCOV: PID=1 instance=200 analog-input 2 time remaining=0 seconds
UCOV: present-value
UCOV: status-flags
*/
// Determine if it CAN subscribe first
if($object->device->supportsService('Subscribe-COV')) {
$instance = $object->device_instance;
$identifier = $object->identifier;
$type = $object->numeric_type;
$statement = "bacscov $instance $type $identifier 1 unconfirmed";
$command = config( 'optimize.bacnet_stack' ) . $statement;
$output = run_command( $command );
if ( strpos( $output, 'SubscribeCOV Acknowledged!' ) > -1 ) {
$object->update( [ 'subscribed_at' => Carbon::now() ] );
return true;
} else {
return false;
}
} else {
return 'Unable to COV to device ' . $object->device_instance . ' due to hardware limitation.';
}
}
}
<?php
namespace App\Http\Controllers;
use App\Object;
use App\User;
use \Spatie\Activitylog\Models\Activity;
use Illuminate\Http\Request;
class ActivityLogController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$logs = Activity::orderBy('created_at', 'reverse')->paginate( 10 );
return view( 'activitylog.index', compact( 'logs') );
}
/**
* Search for a specific log
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function search(Request $request) {
$searchType = $request->searchType;
if($searchType === "object" || $searchType === "string" || $searchType === "user") {
// Proceed!
} else {
$searchType = "string";
}
switch ( $searchType ) {
case "object":
$object = Object::findShorthand( $request->search );
if ( $object instanceof Object ) {
$logs = Activity::where( 'subject_type', 'App\Object' )->where( 'subject_id', $object->id )->orderBy('created_at', 'reverse')->paginate( 10 );
} else {
$logs = collect();
}
break;
case "user":
$user = User::where( 'name', 'LIKE', "%" . $request->search . "%" )->orWhere( 'email', 'LIKE', "%" . $request->search . "%" )->first();
if ( $user instanceof User ) {
$logs = Activity::where( 'causer_type', 'App\User' )->where( 'causer_id', $user->id )->orderBy('created_at', 'reverse')->paginate( 10 );
} else {
$logs = collect();
}
break;
case "string":
$logs = Activity::where( 'description', 'LIKE', '%' . $request->search . '%' )->orderBy('created_at', 'reverse')->paginate( 10 );
break;
}
return view( 'activitylog.index', compact( 'logs') );
}
}
@extends('layouts.app')
@section('content')
<div class="container">
<style>
i.fa { color: white; }
[v-cloak] { display:none; }
</style>
<a href="/tilepieces" class="btn btn-primary"><i class="fa fa-arrow-circle-o-left" aria-hidden="true"></i> Back to Tilepieces</a>
<div class="col-12 offset-sm-1 col-sm-10 offset-md-2 col-md-8 mt-2">
{!! Form::open(['action' => ['TilepiecesController@store'], 'class' => '']) !!}
<div class="form-group row">
{{ Form::label('Device', null, ['class' => 'col-12 col-sm-3 col-form-label']) }}
<div class="col-12 col-sm-9">
{!! Form::select('devices', $deviceList, null, ['id' => 'devices', 'class' => 'form-control', 'placeholder' => 'Pick a BACNET device...', 'onchange' => 'getObjects()']) !!}
</div>
</div>
<div class="form-group row">
{{ Form::label('Object', null, ['class' => 'col-12 col-sm-3 col-form-label']) }}
<div class="col-12 col-sm-9">
{!! Form::select('object_shorthand', [], null, ['id' => 'object_shorthand', 'class' => 'form-control', 'placeholder' => 'Pick an object...']) !!}
</div>
</div>
<div class="form-group row">
{{ Form::label('Tiles', null, ['class' => 'col-12 col-sm-3 col-form-label']) }}
<div class="col-12 col-sm-9">
{!! Form::select('tile_slug', $tiles, $selectedTileSlug ?? null, ['class' => 'form-control', 'placeholder' => 'Pick a tile...']) !!}
</div>
</div>
<div class="form-group row">
{{ Form::label('Short Name', null, ['class' => 'col-12 col-sm-3 col-form-label']) }}
<div class="col-12 col-sm-9">
{!! Form::text('short_name', null, ['class' => 'form-control', 'v-model' => 'formName']) !!}
</div>
</div>
<div class="form-group row">
{{ Form::label('Position', null, ['class' => 'col-12 col-sm-3 col-form-label']) }}
<div class="col-12 col-sm-9">
{!! Form::text('position', null, ['class' => 'form-control']) !!}
</div>
</div>
<div class="form-group row">
{{ Form::label('Long Name', null, ['class' => 'col-12 col-sm-3 col-form-label']) }}
<div class="col-12 col-sm-9">
{!! Form::text('long_name', null, ['class' => 'form-control', 'v-model' => 'formName']) !!}
</div>
</div>
<div class="form-group row">
{{ Form::label('Order', null, ['class' => 'col-12 col-sm-3 col-form-label']) }}
<div class="col-12 col-sm-9">
{!! Form::text('order', null, ['class' => 'form-control']) !!}
</div>
</div>
<div class="form-group row">
{{ Form::label('Control', null, ['class' => 'col-12 col-sm-3 col-form-label']) }}
<div class="col-12 col-sm-9">
{!! Form::select('control', config('optimize.control_types'), null, ['class' => 'form-control', 'placeholder' => 'User Settable by...']) !!}
</div>
</div>
<div class="form-group row">
<div class="col-12 offset-sm-3 col-sm-9">
{!! Form::submit('Create Tilepiece', ['class' => 'btn btn-primary']) !!}
</div>
</div>
{!! Form::close() !!}
</div>
@include('layouts.errors')
</div>
@endsection
@section('afterjs')
<script>
function getObjects() {
var deviceInstance = String($('#devices').find(":selected").val());
$.getJSON("/deviceObjects/" + deviceInstance, null, function (data) {
$("#object_shorthand option").remove();
$.each(data, function (index, item) {
$("#object_shorthand").append(
$("<option></option>")
.text(item.list_name)
.val(item.shorthand)
);
});
});
}
$("select").css('width', '100%').select2();
</script>
{{--@include('tilepieces.vue')--}}
@append
<?php
namespace App\Http\Controllers;
use App\Notifications\InviteSent;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Junaidnasir\Larainvite\Facades\Invite;
class InvitationsController extends Controller {
public function index() {
$invitations = auth()->user()->invitations;
return view( 'invitations.index', compact( 'invitations' ) );
}
public function create( Request $request ) {
$this->validate( $request, [ 'email' => 'unique:users,email', ] );
$user_id = auth()->user()->id;
$code = Invite::invite( $request->email, $user_id );
$invitation = Invite::get( $code );
auth()->user()->notify( new InviteSent( $invitation ) );
flash( 'Invite sent to ' . $invitation->email );
return redirect( action( 'InvitationsController@index' ) );
}
public function acceptInvite( $code ) {
try {
$invitation = Invite::get( $code );
} catch ( Exception $e ) {
return view( 'errors.404' );
}
if ( isset( $invitation ) ) {
return view( 'invitations.accept', compact( 'invitation' ) );
}
}
public function createUser( Request $request ) {
// ensure invite still valid
try {
$invitation = Invite::get( $request->code );
} catch ( Exception $e ) {
return view( 'errors.404' );
}
if ( isset( $invitation ) ) {
// check that email hasnt changed from invite and the user doesnt exist
// make sure passwords match AND MEET MINIMUM STANDARDS
$this->validate( $request,
[
"name" => 'min:2|max:255',
"password" => 'required|min:2|max:255|confirmed',
"password_confirmation" => 'required',
"email" => 'unique:users,email|size:' . strlen( $invitation->email ),
] );
// create user
$newUser = $request->except( '_token' );
$newUser[ 'password' ] = bcrypt( $request->password );
if ( Invite::isAllowed( $invitation->code, $invitation->email ) ) {
Invite::consume( $invitation->code );
$user = User::create( $newUser );
} else {
if ( $invitation->status == 'expired' ) {
flash( 'Uh oh! Your invitation code has expired.', 'danger' );
} elseif ( $invitation->status == 'canceled' ) {
flash( 'Uh oh! Your invitation code has been canceled.', 'danger' );
} elseif ( $invitation->status == 'successful' ) {
flash( 'Your invitation code already been used!', 'danger' );
}
return redirect()->back();
}
// log in as user
Auth::login( $user, true );
// redirect to "/"
flash( 'Your account has been created and you are logged in to ' . $user->email );
return redirect( '/equipment' );
}
}
}