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

Alarm routing

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

A complete alarm pipeline. From subscription → persistence → severity routing (Notifier) → operator UI acknowledgement.

Architecture

text pipeline
OPC UA server     Daemon (auto-publish)              Symfony
─────────────     ─────────────────────              ─────────────────
EventNotifier ──► PSR-14: EventNotificationReceived  EventDispatcher

                                                       ├─► PersistAlarm        (Doctrine)
                                                       ├─► RouteAlarmToOps      (Notifier)
                                                       └─► BroadcastAlarm        (Mercure)

Operator UI ack


                                                AlarmAckController → $client->call(ConditionType, Acknowledge, [...])

                                                       ▼  (server emits)
                                                EventNotificationReceived (isAcked=true)

                                                       └─► PersistAlarm updates is_acked

Doctrine schema

php src/Entity/PlcAlarm.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'plc_alarms', indexes: [
    new ORM\Index(columns: ['is_active', 'is_acked', 'severity']),
])]
class PlcAlarm
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'bigint')]
    public ?int $id = null;

    /** The clientHandle of the event-monitored item this came from. */
    #[ORM\Column(type: 'integer')]
    public int $clientHandle;

    #[ORM\Column(type: 'string', length: 64, unique: true)]
    public string $eventId;

    #[ORM\Column(type: 'string', length: 256, nullable: true)]
    public ?string $eventType = null;

    #[ORM\Column(type: 'string', nullable: true)]
    public ?string $sourceName = null;

    #[ORM\Column(type: 'integer', nullable: true)]
    public ?int $severity = null;

    #[ORM\Column(type: 'text', nullable: true)]
    public ?string $message = null;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    public ?\DateTimeImmutable $occurredAt = null;

    #[ORM\Column(type: 'boolean')]
    public bool $isActive = true;

    #[ORM\Column(type: 'boolean')]
    public bool $isAcked = false;
}

Plus an ack audit table:

php src/Entity/PlcAlarmAck.php
#[ORM\Entity]
class PlcAlarmAck
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    public ?int $id = null;

    #[ORM\ManyToOne(targetEntity: PlcAlarm::class)]
    public PlcAlarm $alarm;

    #[ORM\Column(type: 'string', length: 100)]
    public string $userId;

    #[ORM\Column(type: 'text', nullable: true)]
    public ?string $comment = null;

    #[ORM\Column(type: 'datetime_immutable')]
    public \DateTimeImmutable $ackedAt;
}

Subscription setup

text bundle config
php_opcua_symfony_opcua:
    session_manager:
        auto_publish: true
    connections:
        default:
            endpoint:     '%env(OPCUA_ENDPOINT)%'
            auto_connect: true
            subscriptions:
                -
                    publishing_interval: 1000.0
                    event_monitored_items:
                        - node_id: 'ns=0;i=2253'
                          client_handle: 100
                          select_fields:
                              - EventId
                              - EventType
                              - SourceName
                              - Time
                              - Message
                              - Severity
                              - ActiveState/Id
                              - AckedState/Id

Listeners

Persist

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

use App\Entity\PlcAlarm;
use Doctrine\ORM\EntityManagerInterface;
use PhpOpcua\Client\Event\EventNotificationReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

final class PersistAlarm
{
    public function __construct(private EntityManagerInterface $em) {}

    #[AsEventListener]
    public function __invoke(EventNotificationReceived $event): void
    {
        $f = $event->eventFields;
        $eventId = isset($f['EventId']) ? bin2hex((string) $f['EventId']) : null;

        if ($eventId === null) return;

        $alarm = $this->em->getRepository(PlcAlarm::class)
            ->findOneBy(['eventId' => $eventId]) ?? new PlcAlarm();

        $alarm->eventId      = $eventId;
        $alarm->clientHandle = $event->clientHandle;
        $alarm->eventType    = isset($f['EventType']) ? (string) $f['EventType'] : null;
        $alarm->sourceName   = $f['SourceName']  ?? null;
        $alarm->severity     = isset($f['Severity']) ? (int) $f['Severity'] : null;
        $alarm->message      = isset($f['Message'])  ? (string) $f['Message'] : null;
        $alarm->occurredAt   = $f['Time'] instanceof \DateTimeInterface
            ? \DateTimeImmutable::createFromInterface($f['Time']) : null;
        $alarm->isActive     = (bool) ($f['ActiveState/Id'] ?? true);
        $alarm->isAcked      = (bool) ($f['AckedState/Id']  ?? false);

        $this->em->persist($alarm);
        $this->em->flush();
    }
}

Route

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

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

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

    #[AsEventListener]
    public function __invoke(EventNotificationReceived $event): void
    {
        $f = $event->eventFields;
        $severity = isset($f['Severity']) ? (int) $f['Severity'] : 0;
        $active   = (bool) ($f['ActiveState/Id'] ?? true);

        if (!$active || $severity < 400) {
            return;
        }

        $this->notifier->send(
            new PlcAlarmNotification(
                source:   $f['SourceName'] ?? 'unknown',
                severity: $severity,
                message:  (string) ($f['Message'] ?? ''),
            ),
            new Recipient('[email protected]', '+15551234567'),
        );
    }
}

PlcAlarmNotification is the Notification class — see Integrations · Notifier. For a typed alternative, listen on the alarm-derived event PhpOpcua\Client\Event\AlarmActivated (which carries $sourceName / $severity / $message as non-null fields) when an alarm transitions to active.

Broadcast

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

use PhpOpcua\Client\Event\EventNotificationReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

final class BroadcastAlarm
{
    public function __construct(private HubInterface $hub) {}

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

        $this->hub->publish(new Update(
            topics: ['/plc/alarms'],
            data: json_encode([
                'event_id'  => isset($f['EventId']) ? bin2hex((string) $f['EventId']) : null,
                'source'    => $f['SourceName'] ?? null,
                'severity'  => $f['Severity']   ?? null,
                'message'   => $f['Message']    ?? null,
                'is_active' => $f['ActiveState/Id'] ?? null,
                'is_acked'  => $f['AckedState/Id']  ?? null,
            ]),
            private: true,
        ));
    }
}

The acknowledge endpoint

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

use App\Entity\PlcAlarmAck;
use App\Service\AlarmAcknowledgeService;
use Doctrine\ORM\EntityManagerInterface;
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,
        private EntityManagerInterface $em,
    ) {}

    #[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);
        }

        $audit = (new PlcAlarmAck())
            ->setAlarm($this->em->getRepository(\App\Entity\PlcAlarm::class)->findOneBy(['eventId' => $eventId]))
            ->setUserId($this->getUser()->getUserIdentifier())
            ->setComment($comment)
            ->setAckedAt(new \DateTimeImmutable());
        $this->em->persist($audit);
        $this->em->flush();

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

The ack service

php src/Service/AlarmAcknowledgeService.php
namespace App\Service;

use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Types\BuiltinType;

final class AlarmAcknowledgeService
{
    public function __construct(private OpcUaClientInterface $client) {}

    public function acknowledge(string $eventIdHex, string $comment): void
    {
        $result = $this->client->call(
            'ns=0;i=2782',                                                    // ConditionType
            'ns=0;i=9111',                                                    // Acknowledge
            [
                new \PhpOpcua\Client\Types\Variant(hex2bin($eventIdHex), BuiltinType::ByteString),
                ['locale' => 'en', 'text' => $comment],                        // LocalizedText
            ],
        );

        if ($result->statusCode !== 0) {
            throw new \RuntimeException(sprintf('Ack failed: 0x%X', $result->statusCode));
        }
    }
}

A Notifier-routed alert

See Integrations · Notifier for the PlcAlarmNotification class shape.

Severity routing config

text config/packages/notifier.yaml
framework:
    notifier:
        channel_policy:
            urgent: ['email', 'sms', 'chat/slack']   # sev >= 900
            high:   ['email', 'chat/slack']           # sev 700-899
            medium: ['email']                          # sev 400-699
            low:    ['email']

The Notification's importance field maps to one of these.