symfony-opcua · master
Docs · Events

Alarm events

EventNotificationReceived and the alarm-derived events (AlarmActivated, LimitAlarmExceeded, …) — the alarm pipeline. Severity-based routing via the Notifier component, acknowledgement endpoints, audit chains.

OPC UA distinguishes data changes (a value moved) from events (something happened: a threshold was crossed, a condition fired). Events arrive as PhpOpcua\Client\Event\EventNotificationReceived. When the event payload matches an alarm pattern, opcua-client additionally dispatches one of the specific alarm events from opcua-client/docs/observability/event-reference.md (e.g. AlarmActivated, AlarmAcknowledged, LimitAlarmExceeded, OffNormalAlarmTriggered).

All classes live in PhpOpcua\Client\Event\ (singular).

Note

Publish-loop driven. Like data events, alarm events only fire while something is calling publish() on the subscription — in managed mode that's the daemon with auto_publish: true.

Subscribing to events

Create an event monitored item on a notifier node (typically Server / ns=0;i=2253):

php event subscription
$client = $this->opcua->connect();
$sub = $client->createSubscription(publishingInterval: 1000.0);

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

The 4-arg shape of createEventMonitoredItem is ($subscriptionId, $nodeId, $selectFields, $clientHandle).

EventNotificationReceived — field reference

opcua-client/src/Event/EventNotificationReceived.php:

Field Type Meaning
$client OpcUaClientInterface The live client instance
$subscriptionId int Server subscription id
$sequenceNumber int Publish sequence number
$clientHandle int The handle you assigned
$eventFields array<string, mixed> {name => value} map of select fields

The select-field names you supplied to createEventMonitoredItem become the keys of $eventFields. Common keys:

Key Type / source
EventId string (binary, bin2hex() for log lines)
EventType NodeId string
SourceName string
Time DateTimeInterface or string
Message LocalizedText or string
Severity int (1–1000)

EventNotificationReceived does not flatten event fields into top-level properties — read them out of $eventFields.

Listener — record alarms

php src/EventListener/RecordAlarm.php
namespace App\EventListener;

use App\Message\RecordAlarm as RecordAlarmMessage;
use PhpOpcua\Client\Event\EventNotificationReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\MessageBusInterface;

final class RecordAlarm
{
    public function __construct(private MessageBusInterface $bus) {}

    #[AsEventListener]
    public function __invoke(EventNotificationReceived $event): void
    {
        $f = $event->eventFields;

        $this->bus->dispatch(new RecordAlarmMessage(
            clientHandle: $event->clientHandle,
            eventId:      isset($f['EventId']) ? bin2hex((string) $f['EventId']) : null,
            eventType:    isset($f['EventType']) ? (string) $f['EventType'] : null,
            source:       $f['SourceName'] ?? null,
            severity:     isset($f['Severity']) ? (int) $f['Severity'] : null,
            message:      (string) ($f['Message'] ?? ''),
            occurredAt:   $f['Time'] ?? null,
        ));
    }
}

Handler persists to PlcAlarm Doctrine entity — see Recipes · Alarm routing.

Severity-based routing via Notifier

When you want a derived alarm event with structured fields (so you do not need to crack $eventFields open in every listener), listen on the alarm-specific class AlarmActivated instead — it carries typed fields:

AlarmActivated field Type Meaning
$client client live client
$subscriptionId int subscription id
$clientHandle int handle
$sourceName string alarm source
$severity int 1–1000
$message string alarm message
text config/notifier.yaml
framework:
    notifier:
        chatter_transports:
            slack: '%env(SLACK_DSN)%'
        texter_transports:
            twilio: '%env(TWILIO_DSN)%'
        channel_policy:
            urgent: ['email', 'sms']
            high:   ['email', 'chat/slack']
            low:    ['email']

The Notification class:

php src/Notification/PlcAlarmNotification.php
namespace App\Notification;

use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Message\SmsMessage;
use Symfony\Component\Notifier\Notification\ChatNotificationInterface;
use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Notification\SmsNotificationInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;

final class PlcAlarmNotification extends Notification implements
    EmailNotificationInterface,
    ChatNotificationInterface,
    SmsNotificationInterface
{
    public function __construct(
        public readonly string $source,
        public readonly int $severity,
        public readonly string $message,
    ) {
        parent::__construct("PLC alarm — sev {$severity}");
        $this->importance($this->mapImportance());
    }

    private function mapImportance(): string
    {
        return match (true) {
            $this->severity >= 900 => Notification::IMPORTANCE_URGENT,
            $this->severity >= 700 => Notification::IMPORTANCE_HIGH,
            $this->severity >= 400 => Notification::IMPORTANCE_MEDIUM,
            default                => Notification::IMPORTANCE_LOW,
        };
    }

    public function asEmailMessage(RecipientInterface $r, ?string $transport = null): ?EmailMessage
    {
        return EmailMessage::fromNotification($this, $r);
    }

    public function asChatMessage(RecipientInterface $r, ?string $transport = null): ?ChatMessage
    {
        return new ChatMessage("PLC alarm sev={$this->severity}: {$this->source}{$this->message}");
    }

    public function asSmsMessage(RecipientInterface $r, ?string $transport = null): ?SmsMessage
    {
        return new SmsMessage($r->getPhone(), "PLC sev={$this->severity}: {$this->source}");
    }
}

The listener:

php src/EventListener/RouteAlarmToOps.php
namespace App\EventListener;

use App\Notification\PlcAlarmNotification;
use PhpOpcua\Client\Event\AlarmActivated;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;

final class RouteAlarmToOps
{
    public function __construct(private NotifierInterface $notifier) {}

    #[AsEventListener]
    public function __invoke(AlarmActivated $event): void
    {
        if ($event->severity < 400) return;

        $this->notifier->send(
            new PlcAlarmNotification(
                source:   $event->sourceName,
                severity: $event->severity,
                message:  $event->message,
            ),
            new Recipient('[email protected]', '+15551234567'),
        );
    }
}

Notifier::send() consults the channel_policy and routes based on the notification's importance.

Limit alarms

LimitAlarmExceeded is dispatched in addition to AlarmActivated when the alarm is a LimitAlarmType variant. It carries $limitState (HighHigh, High, Low, LowLow):

use PhpOpcua\Client\Event\LimitAlarmExceeded;

#[AsEventListener]
public function __invoke(LimitAlarmExceeded $event): void
{
    if ($event->limitState === 'HighHigh') {
        // stop the line
    }
}

Acknowledgement endpoint

php src/Controller/AlarmAckController.php
namespace App\Controller;

use App\Service\AlarmAcknowledgeService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class AlarmAckController extends AbstractController
{
    public function __construct(private AlarmAcknowledgeService $svc) {}

    #[Route('/api/alarms/{eventId}/ack', methods: ['POST'])]
    #[IsGranted('ROLE_OPERATOR')]
    public function ack(string $eventId, Request $request): JsonResponse
    {
        $comment = (string) ($request->toArray()['comment'] ?? '');

        try {
            $this->svc->acknowledge($eventId, $comment);
        } catch (\RuntimeException $e) {
            return $this->json(['error' => $e->getMessage()], 422);
        }

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

AlarmAcknowledgeService calls the OPC UA method via OpcUaClientInterface::call() — see Operations · Method calls.

Mercure broadcast for alarms

php alarm broadcast
use PhpOpcua\Client\Event\AlarmActivated;

#[AsEventListener]
public function __invoke(AlarmActivated $event): void
{
    $this->hub->publish(new Update(
        topics: ['/plc/alarms'],
        data: json_encode([
            'source'   => $event->sourceName,
            'severity' => $event->severity,
            'message'  => $event->message,
        ]),
        private: true,
    ));
}

The browser subscribes to the private topic (with auth) and updates in real time.

Filtering — server-side beats client-side

For a noisy plant emitting thousands of low-severity events, push the filter to the server using an OPC UA event filter in the request payload (or in your subscription-declaration config). Filters drop events before they hit the wire — your listener never sees them. See opcua-client event-filter documentation for the supported predicates.

Documentation