laravel-opcua · v4.3.x
Docs · Recipes

Alarm routing

End-to-end alarm pipeline: subscription, persistence, severity-based routing, acknowledgement endpoint, and audit chain. The Laravel-native shape most plants converge on.

A complete alarm pipeline. From the OPC UA subscription that discovers events, through persistence and routing, to the operator UI's acknowledgement.

Architecture

text alarm pipeline
OPC UA server     Daemon (auto-publish)      Laravel listeners on
─────────────────  ─────────────────────────  ─────────────────────────
EventNotifier ──►  Dispatches the real        PhpOpcua\Client\Event\
                   PhpOpcua\Client\Event\     EventNotificationReceived
                   * classes (incl. the       AlarmActivated
                   AlarmActivated /           LimitAlarmExceeded
                   LimitAlarmExceeded /       │
                   AlarmAcknowledged          ├──► PersistAlarm  (DB)
                   variants when payload      ├──► RouteAlarm    (Notification)
                   matches an alarm shape)    └──► BroadcastAlarm (UI)

[Operator clicks Ack in UI]


                                              AcknowledgeAlarmService


                                              Opcua::call(ConditionType, Acknowledge, [...])

                                              ▼ (server emits)
                                              AlarmAcknowledged + EventNotificationReceived

                                              ├──► PersistAlarm  (update is_acked)
                                              └──► BroadcastAlarm

Migrations

php alarms table
return new class extends Migration {
    public function up(): void
    {
        Schema::create('plc_alarms', function (Blueprint $table) {
            $table->id();
            $table->string('connection', 64);
            $table->string('event_id', 64)->index();
            $table->string('event_type', 256)->nullable();
            $table->string('source_node_id')->nullable();
            $table->string('source_name')->nullable();
            $table->integer('severity')->nullable();
            $table->text('message')->nullable();
            $table->timestamp('occurred_at', 6)->nullable();
            $table->boolean('is_active')->default(true);
            $table->boolean('is_acked')->default(false);
            $table->timestamps();

            $table->index(['is_active', 'is_acked', 'severity']);
        });

        Schema::create('plc_alarm_acks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('plc_alarm_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained();
            $table->text('comment')->nullable();
            $table->timestamp('acked_at');
        });
    }
};

Models

php PlcAlarm model
namespace App\Models;

use Illuminate\Database\Eloquent\{Model, Relations\HasMany};

class PlcAlarm extends Model
{
    protected $guarded = [];
    protected $casts = [
        'occurred_at' => 'datetime',
        'is_active'   => 'boolean',
        'is_acked'    => 'boolean',
        'severity'    => 'integer',
    ];

    public function acks(): HasMany
    {
        return $this->hasMany(PlcAlarmAck::class);
    }

    public function scopeActive($q)    { return $q->where('is_active', true); }
    public function scopeUnacked($q)   { return $q->where('is_acked', false); }
    public function scopeCritical($q)  { return $q->where('severity', '>=', 800); }
}

class PlcAlarmAck extends Model
{
    public $timestamps = false;
    protected $guarded = [];
    protected $casts = ['acked_at' => 'datetime'];
}

The subscription

A scheduled / supervisor-managed command sets up the subscription at boot:

php setup command
class SubscribeToAlarms extends Command
{
    protected $signature = 'opcua:subscribe-alarms';

    public function handle(\PhpOpcua\LaravelOpcua\OpcuaManager $opcua): int
    {
        $client = $opcua->connection();
        $sub = $client->createSubscription(publishingInterval: 1000.0);

        $client->createEventMonitoredItem(
            subscriptionId: $sub->subscriptionId,
            nodeId:         'ns=0;i=2253',   // Server node
            selectFields:   [
                'EventId', 'EventType', 'SourceNode', 'SourceName',
                'Time', 'Message', 'Severity',
                'ActiveState/Id', 'AckedState/Id',
            ],
            clientHandle:   100,
        );

        $this->info('Subscribed to alarms.');

        // In managed mode, just return — the daemon holds the sub
        return self::SUCCESS;
    }
}

Run once after deploy, or auto-run from a systemd ExecStartPost.

Listeners

Persist

php PersistAlarm listener
namespace App\Listeners;

use App\Models\PlcAlarm;
use Illuminate\Contracts\Queue\ShouldQueue;
use PhpOpcua\Client\Event\EventNotificationReceived;

class PersistAlarm implements ShouldQueue
{
    public string $queue = 'opcua-alarms';

    public function handle(EventNotificationReceived $event): void
    {
        $f = $event->eventFields;
        $eventId = isset($f['EventId']) ? bin2hex($f['EventId']) : null;
        if ($eventId === null) {
            return;
        }

        PlcAlarm::updateOrCreate(
            ['event_id' => $eventId],
            [
                'event_type'      => (string) ($f['EventType'] ?? ''),
                'source_node_id'  => isset($f['SourceNode']) ? (string) $f['SourceNode'] : null,
                'source_name'     => $f['SourceName'] ?? null,
                'severity'        => $f['Severity'] ?? null,
                'message'         => $f['Message'] ?? null,
                'occurred_at'     => $f['Time'] ?? null,
                'is_active'       => (bool) ($f['ActiveState/Id'] ?? $f['ActiveState'] ?? true),
                'is_acked'        => (bool) ($f['AckedState/Id']  ?? $f['AckedState']  ?? false),
            ],
        );
    }
}

updateOrCreate handles both "new alarm" and "alarm state-change" (active → inactive, unacked → acked).

Route

php RouteAlarmNotification listener
namespace App\Listeners;

use App\Notifications\PlcAlarmRaised;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Support\Facades\Notification;
use PhpOpcua\Client\Event\AlarmActivated;

class RouteAlarmNotification implements ShouldQueue
{
    public string $queue = 'opcua-alarms';

    public function handle(AlarmActivated $event): void
    {
        if (($event->severity ?? 0) < 400) {
            return;     // info / warning level — DB only, no routing
        }

        $notif = new PlcAlarmRaised(
            source:   $event->sourceName ?? 'unknown',
            severity: $event->severity,
            message:  $event->message,
        );

        (new AnonymousNotifiable())
            ->route('slack', config('alarms.recipients.slack_channel'))
            ->route('mail',  config('alarms.recipients.broadcast_email'))
            ->notify($notif);
    }
}

PlcAlarmRaised is the Notification class — see Integrations · Notifications.

Broadcast

php BroadcastAlarm listener
namespace App\Listeners;

use App\Events\AlarmBroadcasted;

class BroadcastAlarm
{
    public function handle(\PhpOpcua\Client\Event\EventNotificationReceived $event): void
    {
        $f = $event->eventFields;
        $eventId = isset($f['EventId']) ? bin2hex($f['EventId']) : null;
        if ($eventId === null) return;

        broadcast(new AlarmBroadcasted(
            eventId:  $eventId,
            source:   $f['SourceName'] ?? null,
            severity: $f['Severity'] ?? 0,
            message:  $f['Message'] ?? '',
            isActive: (bool) ($f['ActiveState/Id'] ?? $f['ActiveState'] ?? true),
            isAcked:  (bool) ($f['AckedState/Id']  ?? $f['AckedState']  ?? false),
        ));
    }
}
class AlarmBroadcasted implements \Illuminate\Contracts\Broadcasting\ShouldBroadcastNow
{
    public function __construct(
        public readonly string $eventId,
        public readonly ?string $source,
        public readonly int $severity,
        public readonly string $message,
        public readonly bool $isActive,
        public readonly bool $isAcked,
    ) {}

    public function broadcastOn(): \Illuminate\Broadcasting\Channel
    {
        return new \Illuminate\Broadcasting\PrivateChannel('plc.alarms');
    }
}

Register all three

php EventServiceProvider
use PhpOpcua\Client\Event\EventNotificationReceived;
use PhpOpcua\Client\Event\AlarmActivated;

protected $listen = [
    EventNotificationReceived::class => [
        PersistAlarm::class,
        BroadcastAlarm::class,
    ],
    AlarmActivated::class => [
        RouteAlarmNotification::class,
    ],
];

The acknowledge endpoint

php AcknowledgeAlarmController
namespace App\Http\Controllers;

use App\Models\{PlcAlarm, PlcAlarmAck};
use Illuminate\Http\{Request, JsonResponse};
use PhpOpcua\Client\Types\BuiltinType;
use PhpOpcua\LaravelOpcua\OpcuaManager;

class AcknowledgeAlarmController
{
    public function ack(
        Request $request,
        OpcuaManager $opcua,
        string $eventId,
    ): JsonResponse {
        $request->validate(['comment' => 'nullable|string|max:255']);

        $alarm = PlcAlarm::where('event_id', $eventId)->firstOrFail();
        $this->authorize('ack', $alarm);

        // Call the OPC UA Acknowledge method on the ConditionType node
        $result = $opcua->connection($alarm->connection)->call(
            objectId:        'ns=0;i=2782',
            methodId:        'ns=0;i=9111',
            inputArguments:  [
                new \PhpOpcua\Client\Types\Variant(hex2bin($eventId), BuiltinType::ByteString),
                ['locale' => 'en', 'text' => $request->input('comment') ?? ''],
            ],
        );

        if (! \PhpOpcua\Client\Types\StatusCode::isGood($result->statusCode)) {
            return response()->json([
                'error'  => 'ack-failed',
                'status' => \PhpOpcua\Client\Types\StatusCode::getName($result->statusCode),
            ], 422);
        }

        // Record the ack locally — the server-emitted update arrives via
        // EventNotificationReceived (AckedState=true) and lands in
        // PlcAlarm via PersistAlarm. We separately record who acked it.
        PlcAlarmAck::create([
            'plc_alarm_id' => $alarm->id,
            'user_id'      => $request->user()->id,
            'comment'      => $request->input('comment'),
            'acked_at'     => now(),
        ]);

        return response()->json(['status' => 'acked']);
    }
}

Route:

php routes/api.php
Route::middleware(['auth:sanctum'])
    ->post('/alarms/{eventId}/ack', [AcknowledgeAlarmController::class, 'ack']);

Policy

php PlcAlarmPolicy
namespace App\Policies;

use App\Models\{PlcAlarm, User};

class PlcAlarmPolicy
{
    public function ack(User $user, PlcAlarm $alarm): bool
    {
        if (! $user->hasRole('operator')) return false;

        // Per-line scoping based on the source node
        return $user->canAccessLine($alarm->source_name);
    }
}

Register in AuthServiceProvider.

Operator UI

The alarms list (Filament or plain Livewire):

php Livewire alarms list
class AlarmsList extends Component
{
    public function mount(): void
    {
        $this->listen('echo:plc.alarms,App\\Events\\AlarmBroadcasted', 'refresh');
    }

    public function render()
    {
        $alarms = PlcAlarm::active()->unacked()->orderByDesc('severity')
            ->orderByDesc('occurred_at')->limit(50)->get();

        return view('livewire.alarms-list', compact('alarms'));
    }

    public function ack(int $alarmId, string $comment = ''): void
    {
        $alarm = PlcAlarm::findOrFail($alarmId);
        $this->authorize('ack', $alarm);

        Http::withToken(auth()->user()->createToken('alarms')->plainTextToken)
            ->post(route('alarms.ack', $alarm->event_id), ['comment' => $comment]);
    }
}

The UI refreshes on every broadcast — operator sees alarms in real time and ack-able immediately.

Severity routing config

php config/alarms.php
return [
    'recipients' => [
        'slack_channel'   => env('ALARM_SLACK_CHANNEL'),
        'broadcast_email' => env('ALARM_EMAIL'),
    ],
    'thresholds' => [
        'route' => 400,    // below this, DB-only
        'slack' => 700,
        'page'  => 900,    // SMS/phone alert
    ],
];