Initial Upload
This commit is contained in:
575
lib/Connector/CapConnector.php
Normal file
575
lib/Connector/CapConnector.php
Normal file
@@ -0,0 +1,575 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (C) 2024 Xibo Signage Ltd
|
||||
*
|
||||
* Xibo - Digital Signage - https://xibosignage.com
|
||||
*
|
||||
* This file is part of Xibo.
|
||||
*
|
||||
* Xibo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Xibo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace Xibo\Connector;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use Exception;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Location\Coordinate;
|
||||
use Location\Polygon;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Xibo\Event\ScheduleCriteriaRequestEvent;
|
||||
use Xibo\Event\ScheduleCriteriaRequestInterface;
|
||||
use Xibo\Event\WidgetDataRequestEvent;
|
||||
use Xibo\Factory\DisplayFactory;
|
||||
use Xibo\Support\Exception\ConfigurationException;
|
||||
use Xibo\Support\Sanitizer\SanitizerInterface;
|
||||
use Xibo\Widget\Provider\DataProviderInterface;
|
||||
use Xibo\XMR\ScheduleCriteriaUpdateAction;
|
||||
|
||||
/**
|
||||
* A connector to process Common Alerting Protocol (CAP) Data
|
||||
*/
|
||||
class CapConnector implements ConnectorInterface, EmergencyAlertInterface
|
||||
{
|
||||
use ConnectorTrait;
|
||||
|
||||
/** @var DOMDocument */
|
||||
protected DOMDocument $capXML;
|
||||
|
||||
/** @var DOMElement */
|
||||
protected DOMElement $infoNode;
|
||||
|
||||
/** @var DOMElement */
|
||||
protected DOMElement $areaNode;
|
||||
|
||||
/** @var DisplayFactory */
|
||||
private DisplayFactory $displayFactory;
|
||||
|
||||
/**
|
||||
* @param ContainerInterface $container
|
||||
* @return ConnectorInterface
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function setFactories(ContainerInterface $container): ConnectorInterface
|
||||
{
|
||||
$this->displayFactory = $container->get('displayFactory');
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
|
||||
{
|
||||
$dispatcher->addListener(WidgetDataRequestEvent::$NAME, [$this, 'onDataRequest']);
|
||||
$dispatcher->addListener(ScheduleCriteriaRequestEvent::$NAME, [$this, 'onScheduleCriteriaRequest']);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSourceName(): string
|
||||
{
|
||||
return 'cap-connector';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'CAP Connector';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Common Alerting Protocol';
|
||||
}
|
||||
|
||||
public function getThumbnail(): string
|
||||
{
|
||||
return 'theme/default/img/connectors/xibo-cap.png';
|
||||
}
|
||||
|
||||
public function getSettingsFormTwig(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function processSettingsForm(SanitizerInterface $params, array $settings): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* If the requested dataSource is emergency-alert, get the data, process it and add to dataProvider
|
||||
*
|
||||
* @param WidgetDataRequestEvent $event
|
||||
* @return void
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function onDataRequest(WidgetDataRequestEvent $event): void
|
||||
{
|
||||
if ($event->getDataProvider()->getDataSource() !== 'emergency-alert') {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->stopPropagation();
|
||||
|
||||
try {
|
||||
// check if CAP URL is present
|
||||
if (empty($event->getDataProvider()->getProperty('emergencyAlertUri'))) {
|
||||
$this->getLogger()->debug('onDataRequest: Emergency alert not configured.');
|
||||
$event->getDataProvider()->addError(__('Missing CAP URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set cache expiry date to 3 minutes from now
|
||||
$cacheExpire = Carbon::now()->addMinutes(3);
|
||||
|
||||
// Fetch the CAP XML content from the given URL
|
||||
$xmlContent = $this->fetchCapAlertFromUrl($event->getDataProvider(), $cacheExpire);
|
||||
|
||||
if ($xmlContent) {
|
||||
// Initialize DOMDocument and load the XML content
|
||||
$this->capXML = new DOMDocument();
|
||||
$this->capXML->loadXML($xmlContent);
|
||||
|
||||
// Process and initialize CAP data
|
||||
$this->processCapData($event->getDataProvider());
|
||||
|
||||
// Initialize update interval
|
||||
$updateIntervalMinute = $event->getDataProvider()->getProperty('updateInterval');
|
||||
|
||||
// Convert the $updateIntervalMinute to seconds
|
||||
$updateInterval = $updateIntervalMinute * 60;
|
||||
|
||||
// If we've got data, then set our cache period.
|
||||
$event->getDataProvider()->setCacheTtl($updateInterval);
|
||||
$event->getDataProvider()->setIsHandled();
|
||||
|
||||
$capStatus = $this->getCapXmlData('status');
|
||||
$category = $this->getCapXmlData('category');
|
||||
} else {
|
||||
$capStatus = 'No Alerts';
|
||||
$category = '';
|
||||
}
|
||||
|
||||
// initialize status for schedule criteria push message
|
||||
if ($capStatus == 'Actual') {
|
||||
$status = self::ACTUAL_ALERT;
|
||||
} elseif ($capStatus == 'No Alerts') {
|
||||
$status = self::NO_ALERT;
|
||||
} else {
|
||||
$status = self::TEST_ALERT;
|
||||
}
|
||||
|
||||
$this->getLogger()->debug('Schedule criteria push message: status = ' . $status
|
||||
. ', category = ' . $category);
|
||||
|
||||
// Set ttl expiry to 180s since widget sync task runs every 180s and add a bit of buffer
|
||||
$ttl = max($updateInterval ?? 180, 180) + 60;
|
||||
|
||||
// Set schedule criteria update
|
||||
$action = new ScheduleCriteriaUpdateAction();
|
||||
|
||||
// Adjust the QOS value lower than the data update QOS to ensure it arrives first
|
||||
$action->setQos(3);
|
||||
$action->setCriteriaUpdates([
|
||||
['metric' => 'emergency_alert_status', 'value' => $status, 'ttl' => $ttl],
|
||||
['metric' => 'emergency_alert_category', 'value' => $category, 'ttl' => $ttl]
|
||||
]);
|
||||
|
||||
// Initialize the display
|
||||
$displayId = $event->getDataProvider()->getDisplayId();
|
||||
$display = $this->displayFactory->getById($displayId);
|
||||
|
||||
// Criteria push message
|
||||
$this->getPlayerActionService()->sendAction($display, $action);
|
||||
} catch (Exception $exception) {
|
||||
$this->getLogger()
|
||||
->error('onDataRequest: Failed to get results. e = ' . $exception->getMessage());
|
||||
$event->getDataProvider()->addError(__('Unable to get Common Alerting Protocol (CAP) results.'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and process the CAP data
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function processCapData(DataProviderInterface $dataProvider): void
|
||||
{
|
||||
// Array to store configuration data
|
||||
$config = [];
|
||||
|
||||
// Initialize configuration data
|
||||
$config['status'] = $dataProvider->getProperty('status');
|
||||
$config['msgType'] = $dataProvider->getProperty('msgType');
|
||||
$config['scope'] = $dataProvider->getProperty('scope');
|
||||
$config['category'] = $dataProvider->getProperty('category');
|
||||
$config['responseType'] = $dataProvider->getProperty('responseType');
|
||||
$config['urgency'] = $dataProvider->getProperty('urgency');
|
||||
$config['severity'] = $dataProvider->getProperty('severity');
|
||||
$config['certainty'] = $dataProvider->getProperty('certainty');
|
||||
$config['isAreaSpecific'] = $dataProvider->getProperty('isAreaSpecific');
|
||||
|
||||
// Retrieve specific values from the CAP XML for filtering
|
||||
$status = $this->getCapXmlData('status');
|
||||
$msgType = $this->getCapXmlData('msgType');
|
||||
$scope = $this->getCapXmlData('scope');
|
||||
|
||||
// Check if the retrieved CAP data matches the configuration filters
|
||||
if (!$this->matchesFilter($status, $config['status']) ||
|
||||
!$this->matchesFilter($msgType, $config['msgType']) ||
|
||||
!$this->matchesFilter($scope, $config['scope'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Array to store CAP values
|
||||
$cap = [];
|
||||
|
||||
// Initialize CAP values
|
||||
$cap['source'] = $this->getCapXmlData('source');
|
||||
$cap['note'] = $this->getCapXmlData('note');
|
||||
|
||||
// Get all <info> elements
|
||||
$infoNodes = $this->capXML->getElementsByTagName('info');
|
||||
|
||||
foreach ($infoNodes as $infoNode) {
|
||||
$this->infoNode = $infoNode;
|
||||
|
||||
// Extract values from the current <info> node for filtering
|
||||
$category = $this->getInfoData('category');
|
||||
$responseType = $this->getInfoData('responseType');
|
||||
$urgency = $this->getInfoData('urgency');
|
||||
$severity = $this->getInfoData('severity');
|
||||
$certainty = $this->getInfoData('certainty');
|
||||
|
||||
// Check if the current <info> node matches all filters
|
||||
if (!$this->matchesFilter($category, $config['category']) ||
|
||||
!$this->matchesFilter($responseType, $config['responseType']) ||
|
||||
!$this->matchesFilter($urgency, $config['urgency']) ||
|
||||
!$this->matchesFilter($severity, $config['severity']) ||
|
||||
!$this->matchesFilter($certainty, $config['certainty'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize the rest of the CAP values
|
||||
$cap['event'] = $this->getInfoData('event');
|
||||
$cap['urgency'] = $this->getInfoData('urgency');
|
||||
$cap['severity'] = $this->getInfoData('severity');
|
||||
$cap['certainty'] = $this->getInfoData('certainty');
|
||||
$cap['dateTimeEffective'] = $this->getInfoData('effective');
|
||||
$cap['dateTimeOnset'] = $this->getInfoData('onset');
|
||||
$cap['dateTimeExpires'] = $this->getInfoData('expires');
|
||||
$cap['senderName'] = $this->getInfoData('senderName');
|
||||
$cap['headline'] = $this->getInfoData('headline');
|
||||
$cap['description'] = $this->getInfoData('description');
|
||||
$cap['instruction'] = $this->getInfoData('instruction');
|
||||
$cap['contact'] = $this->getInfoData('contact');
|
||||
|
||||
// Retrieve all <area> elements within the current <info> element
|
||||
$areaNodes = $this->infoNode->getElementsByTagName('area');
|
||||
|
||||
if (empty($areaNodes->length)) {
|
||||
// If we don't have <area> elements, then provide CAP without the Area
|
||||
$dataProvider->addItem($cap);
|
||||
} else {
|
||||
// Iterate through each <area> element
|
||||
foreach ($areaNodes as $areaNode) {
|
||||
$this->areaNode = $areaNode;
|
||||
|
||||
$circle = $this->getAreaData('circle');
|
||||
$polygon = $this->getAreaData('polygon');
|
||||
$cap['areaDesc'] = $this->getAreaData('areaDesc');
|
||||
|
||||
// Check if the area-specific filter is enabled
|
||||
if ($config['isAreaSpecific']) {
|
||||
if ($circle || $polygon) {
|
||||
// Get the current display coordinates
|
||||
$displayLatitude = $dataProvider->getDisplayLatitude();
|
||||
$displayLongitude = $dataProvider->getDisplayLongitude();
|
||||
|
||||
// Retrieve area coordinates (circle or polygon) from CAP XML
|
||||
$areaCoordinates = $this->getAreaCoordinates();
|
||||
|
||||
// Check if display coordinates matches the CAP alert area
|
||||
if ($this->isWithinArea($displayLatitude, $displayLongitude, $areaCoordinates)) {
|
||||
$dataProvider->addItem($cap);
|
||||
}
|
||||
} else {
|
||||
// Provide CAP data if no coordinate/s is provided
|
||||
$dataProvider->addItem($cap);
|
||||
}
|
||||
} else {
|
||||
// Provide CAP data if area-specific filter is disabled
|
||||
$dataProvider->addItem($cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetches the CAP (Common Alerting Protocol) XML data from the provided emergency alert URL.
|
||||
*
|
||||
* @param DataProviderInterface $dataProvider
|
||||
* @param Carbon $cacheExpiresAt
|
||||
*
|
||||
* @return string|null
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function fetchCapAlertFromUrl(DataProviderInterface $dataProvider, Carbon $cacheExpiresAt): string|null
|
||||
{
|
||||
$emergencyAlertUrl = $dataProvider->getProperty('emergencyAlertUri');
|
||||
|
||||
$cache = $this->pool->getItem('/emergency-alert/cap/' . md5($emergencyAlertUrl));
|
||||
$data = $cache->get();
|
||||
|
||||
if ($cache->isMiss()) {
|
||||
$cache->lock();
|
||||
$this->getLogger()->debug('Getting CAP data from CAP Feed');
|
||||
|
||||
$httpOptions = [
|
||||
'timeout' => 20, // Wait no more than 20 seconds
|
||||
];
|
||||
|
||||
try {
|
||||
// Make a GET request to the CAP URL using Guzzle HTTP client with defined options
|
||||
$response = $dataProvider
|
||||
->getGuzzleClient($httpOptions)
|
||||
->get($emergencyAlertUrl);
|
||||
|
||||
$this->getLogger()->debug('CAP Feed: uri: ' . $emergencyAlertUrl . ' httpOptions: '
|
||||
. json_encode($httpOptions));
|
||||
|
||||
// Get the response body as a string
|
||||
$data = $response->getBody()->getContents();
|
||||
|
||||
// Cache
|
||||
$cache->set($data);
|
||||
$cache->expiresAt($cacheExpiresAt);
|
||||
$this->pool->saveDeferred($cache);
|
||||
} catch (RequestException $e) {
|
||||
// Log the error with a message specific to CAP data fetching
|
||||
$this->getLogger()->error('Unable to reach the CAP feed URL: '
|
||||
. $emergencyAlertUrl . ' Error: ' . $e->getMessage());
|
||||
|
||||
// Throw a more specific exception message
|
||||
$dataProvider->addError(__('Failed to retrieve CAP data from the specified URL.'));
|
||||
}
|
||||
} else {
|
||||
$this->getLogger()->debug('Getting CAP data from cache');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a specified tag from the CAP XML document.
|
||||
*
|
||||
* @param string $tagName
|
||||
* @return string|null
|
||||
*/
|
||||
private function getCapXmlData(string $tagName): ?string
|
||||
{
|
||||
// Ensure the XML is loaded and the tag exists
|
||||
$node = $this->capXML->getElementsByTagName($tagName)->item(0);
|
||||
|
||||
// Return the node value if the node exists, otherwise return an empty string
|
||||
return $node ? $node->nodeValue : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a specified tag from the current <info> node.
|
||||
*
|
||||
* @param string $tagName
|
||||
* @return string|null
|
||||
*/
|
||||
private function getInfoData(string $tagName): ?string
|
||||
{
|
||||
// Ensure the tag exists within the provided <info> node
|
||||
$node = $this->infoNode->getElementsByTagName($tagName)->item(0);
|
||||
|
||||
// Return the node value if the node exists, otherwise return an empty string
|
||||
return $node ? $node->nodeValue : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a specified tag from the current <area> node.
|
||||
*
|
||||
* @param string $tagName
|
||||
* @return string|null
|
||||
*/
|
||||
private function getAreaData(string $tagName): ?string
|
||||
{
|
||||
// Ensure the tag exists within the provided <area> node
|
||||
$node = $this->areaNode->getElementsByTagName($tagName)->item(0);
|
||||
|
||||
// Return the node value if the node exists, otherwise return an empty string
|
||||
return $node ? $node->nodeValue : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the value of a CAP XML element matches the expected filter value.
|
||||
*
|
||||
* @param string $actualValue
|
||||
* @param string $expectedValue
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function matchesFilter(string $actualValue, string $expectedValue): bool
|
||||
{
|
||||
// If the expected value is 'Any' (empty string) or matches the actual value, the filter passes
|
||||
if (empty($expectedValue) || $expectedValue == $actualValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get area coordinates from CAP XML data.
|
||||
*
|
||||
* Determines if the area is defined as a circle or polygon
|
||||
* and returns the relevant data.
|
||||
*
|
||||
* @return array An array with the area type and coordinates.
|
||||
*/
|
||||
private function getAreaCoordinates(): array
|
||||
{
|
||||
// array to store coordinates data
|
||||
$area = [];
|
||||
|
||||
// Check for a circle area element
|
||||
$circle = $this->getAreaData('circle');
|
||||
if ($circle) {
|
||||
// Split the circle data into center coordinates and radius
|
||||
$circleParts = explode(' ', $circle);
|
||||
$center = explode(',', $circleParts[0]); // "latitude,longitude"
|
||||
$radius = $circleParts[1];
|
||||
|
||||
$area['type'] = 'circle';
|
||||
$area['center'] = ['lat' => $center[0], 'lon' => $center[1]];
|
||||
$area['radius'] = $radius;
|
||||
return $area;
|
||||
}
|
||||
|
||||
// Check for a polygon area element
|
||||
$polygon = $this->getAreaData('polygon');
|
||||
if ($polygon) {
|
||||
// Split the polygon data into multiple points ("lat1,lon1 lat2,lon2 ...")
|
||||
$points = explode(' ', $polygon);
|
||||
|
||||
// Array to store multiple coordinates
|
||||
$polygonPoints = [];
|
||||
|
||||
foreach ($points as $point) {
|
||||
$coords = explode(',', $point);
|
||||
$polygonPoints[] = ['lat' => $coords[0], 'lon' => $coords[1]];
|
||||
}
|
||||
|
||||
$area['type'] = 'polygon';
|
||||
$area['points'] = $polygonPoints;
|
||||
}
|
||||
|
||||
return $area;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the provided display coordinates are inside a defined area (circle or polygon).
|
||||
* If no area coordinates are available, it returns false.
|
||||
*
|
||||
* @param float $displayLatitude
|
||||
* @param float $displayLongitude
|
||||
* @param array $areaCoordinates The coordinates defining the area (circle or polygon).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function isWithinArea(float $displayLatitude, float $displayLongitude, array $areaCoordinates): bool
|
||||
{
|
||||
if (empty($areaCoordinates)) {
|
||||
// No area coordinates available
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize the display coordinate
|
||||
$displayCoordinate = new Coordinate($displayLatitude, $displayLongitude);
|
||||
|
||||
if ($areaCoordinates['type'] == 'circle') {
|
||||
// Initialize the circle's coordinate and radius
|
||||
$centerCoordinate = new Coordinate($areaCoordinates['center']['lat'], $areaCoordinates['center']['lon']);
|
||||
$radius = $areaCoordinates['radius'];
|
||||
|
||||
// Check if the display is within the specified radius of the center coordinate
|
||||
if ($centerCoordinate->hasSameLocation($displayCoordinate, $radius)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Initialize a new polygon
|
||||
$geofence = new Polygon();
|
||||
|
||||
// Add each point to the polygon
|
||||
foreach ($areaCoordinates['points'] as $point) {
|
||||
$geofence->addPoint(new Coordinate($point['lat'], $point['lon']));
|
||||
}
|
||||
|
||||
// Check if the display is within the polygon
|
||||
if ($geofence->contains($displayCoordinate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScheduleCriteriaRequestInterface $event
|
||||
* @return void
|
||||
* @throws ConfigurationException
|
||||
*/
|
||||
public function onScheduleCriteriaRequest(ScheduleCriteriaRequestInterface $event): void
|
||||
{
|
||||
// Initialize Emergency Alerts schedule criteria parameters
|
||||
$event->addType('emergency_alert', __('Emergency Alerts'))
|
||||
->addMetric('emergency_alert_status', __('Status'))
|
||||
->addCondition([
|
||||
'eq' => __('Equal to')
|
||||
])
|
||||
->addValues('dropdown', [
|
||||
self::ACTUAL_ALERT => __('Actual Alerts'),
|
||||
self::TEST_ALERT => __('Test Alerts'),
|
||||
self::NO_ALERT => __('No Alerts')
|
||||
])
|
||||
->addMetric('emergency_alert_category', __('Category'))
|
||||
->addCondition([
|
||||
'eq' => __('Equal to')
|
||||
])
|
||||
->addValues('dropdown', [
|
||||
'Geo' => __('Geo'),
|
||||
'Met' => __('Met'),
|
||||
'Safety' => __('Safety'),
|
||||
'Security' => __('Security'),
|
||||
'Rescue' => __('Rescue'),
|
||||
'Fire' => __('Fire'),
|
||||
'Health' => __('Health'),
|
||||
'Env' => __('Env'),
|
||||
'Transport' => __('Transport'),
|
||||
'Infra' => __('Infra'),
|
||||
'CBRNE' => __('CBRNE'),
|
||||
'Other' => __('Other'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user