Initial Upload

This commit is contained in:
Matt Batchelder
2025-12-02 10:32:59 -05:00
commit 05ce0da296
2240 changed files with 467811 additions and 0 deletions

View File

@@ -0,0 +1,657 @@
<?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 GuzzleHttp\Exception\GuzzleException;
use Stash\Invalidation;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Xibo\Event\WidgetDataRequestEvent;
use Xibo\Event\WidgetEditOptionRequestEvent;
use Xibo\Support\Exception\ConfigurationException;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Support\Sanitizer\SanitizerInterface;
use Xibo\Widget\Provider\DataProviderInterface;
/**
* A connector to get data from the AlphaVantage API for use by the Currencies and Stocks Widgets
*/
class AlphaVantageConnector implements ConnectorInterface
{
use ConnectorTrait;
public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
{
$dispatcher->addListener(WidgetEditOptionRequestEvent::$NAME, [$this, 'onWidgetEditOption']);
$dispatcher->addListener(WidgetDataRequestEvent::$NAME, [$this, 'onDataRequest']);
return $this;
}
public function getSourceName(): string
{
return 'alphavantage';
}
public function getTitle(): string
{
return 'Alpha Vantage';
}
public function getDescription(): string
{
return 'Get Currencies and Stocks data';
}
public function getThumbnail(): string
{
return '';
}
public function getSettingsFormTwig(): string
{
return 'alphavantage-form-settings';
}
/**
* @param SanitizerInterface $params
* @param array $settings
* @return array
*/
public function processSettingsForm(SanitizerInterface $params, array $settings): array
{
if (!$this->isProviderSetting('apiKey')) {
$settings['apiKey'] = $params->getString('apiKey');
$settings['isPaidPlan'] = $params->getCheckbox('isPaidPlan');
$settings['cachePeriod'] = $params->getInt('cachePeriod');
}
return $settings;
}
/**
* If the requested dataSource is either Currencies or stocks, get the data, process it and add to dataProvider
*
* @param WidgetDataRequestEvent $event
* @return void
*/
public function onDataRequest(WidgetDataRequestEvent $event)
{
$dataProvider = $event->getDataProvider();
if ($dataProvider->getDataSource() === 'currencies' || $dataProvider->getDataSource() === 'stocks') {
if (empty($this->getSetting('apiKey'))) {
$this->getLogger()->debug('onDataRequest: Alpha Vantage not configured.');
return;
}
$event->stopPropagation();
try {
if ($dataProvider->getDataSource() === 'stocks') {
$this->getStockResults($dataProvider);
} else if ($dataProvider->getDataSource() === 'currencies') {
$this->getCurrenciesResults($dataProvider);
}
// If we've got data, then set our cache period.
$event->getDataProvider()->setCacheTtl($this->getSetting('cachePeriod', 3600));
} catch (\Exception $exception) {
$this->getLogger()->error('onDataRequest: Failed to get results. e = ' . $exception->getMessage());
if ($exception instanceof InvalidArgumentException) {
$dataProvider->addError($exception->getMessage());
} else {
$dataProvider->addError(__('Unable to contact the AlphaVantage API'));
}
}
}
}
/**
* If the Widget type is stocks, process it and update options
*
* @param WidgetEditOptionRequestEvent $event
* @return void
* @throws NotFoundException
*/
public function onWidgetEditOption(WidgetEditOptionRequestEvent $event): void
{
$this->getLogger()->debug('onWidgetEditOption');
// Pull the widget we're working with.
$widget = $event->getWidget();
if ($widget === null) {
throw new NotFoundException();
}
// We handle the stocks widget and the property with id="items"
if ($widget->type === 'stocks' && $event->getPropertyId() === 'items') {
if (empty($this->getSetting('apiKey'))) {
$this->getLogger()->debug('onWidgetEditOption: AlphaVantage API not configured.');
return;
}
try {
$results = [];
$bestMatches = $this->getSearchResults($event->getPropertyValue() ?? '');
$this->getLogger()->debug('onWidgetEditOption::getSearchResults => ' . var_export([
'bestMatches' => $bestMatches,
], true));
if ($bestMatches === false) {
$results[] = [
'name' => strtoupper($event->getPropertyValue()),
'type' => strtoupper(trim($event->getPropertyValue())),
'id' => $event->getPropertyId(),
];
} else if (count($bestMatches) > 0) {
foreach($bestMatches as $match) {
$results[] = [
'name' => implode(' ', [$match['1. symbol'], $match['2. name']]),
'type' => $match['1. symbol'],
'id' => $event->getPropertyId(),
];
}
}
$event->setOptions($results);
} catch (\Exception $exception) {
$this->getLogger()->error('onWidgetEditOption: Failed to get symbol search results. e = ' . $exception->getMessage());
}
}
}
/**
* Get Stocks data through symbol search
*
* @param string $keywords
* @return array|bool
* @throws GeneralException
*/
private function getSearchResults(string $keywords): array|bool
{
try {
$this->getLogger()->debug('AlphaVantage Connector : getSearchResults is served from the API.');
$request = $this->getClient()->request('GET', 'https://avg.signcdn.com/query', [
'query' => [
'function' => 'SYMBOL_SEARCH',
'keywords' => $keywords,
]
]);
$data = json_decode($request->getBody(), true);
if (array_key_exists('bestMatches', $data)) {
return $data['bestMatches'];
}
if (array_key_exists('Note', $data)) {
return false;
}
return [];
} catch (GuzzleException $guzzleException) {
throw new GeneralException(
'Guzzle exception getting Stocks data . E = '
. $guzzleException->getMessage(),
$guzzleException->getCode(),
$guzzleException
);
}
}
/**
* Get Stocks data, parse it to an array and add each item to the dataProvider
*
* @throws ConfigurationException
* @throws InvalidArgumentException|GeneralException
*/
private function getStockResults(DataProviderInterface $dataProvider): void
{
// Construct the YQL
// process items
$items = $dataProvider->getProperty('items');
if ($items == '') {
$this->getLogger()->error('Missing Items for Stocks Module with WidgetId ' . $dataProvider->getWidgetId());
throw new InvalidArgumentException(__('Add some stock symbols'), 'items');
}
// Parse items out into an array
$items = array_map('trim', explode(',', $items));
foreach ($items as $symbol) {
try {
// Does this symbol have any additional data
$parsedSymbol = explode('|', $symbol);
$symbol = $parsedSymbol[0];
$name = ($parsedSymbol[1] ?? $symbol);
$currency = ($parsedSymbol[2] ?? '');
$result = $this->getStockQuote($symbol, $this->getSetting('isPaidPlan'));
$this->getLogger()->debug(
'AlphaVantage Connector : getStockResults data: ' .
var_export($result, true)
);
$item = [];
foreach ($result['Time Series (Daily)'] as $series) {
$item = [
'Name' => $name,
'Symbol' => $symbol,
'time' => $result['Meta Data']['3. Last Refreshed'],
'LastTradePriceOnly' => round($series['4. close'], 4),
'RawLastTradePriceOnly' => $series['4. close'],
'YesterdayTradePriceOnly' => round($series['1. open'], 4),
'RawYesterdayTradePriceOnly' => $series['1. open'],
'TimeZone' => $result['Meta Data']['5. Time Zone'],
'Currency' => $currency
];
$item['Change'] = round($item['RawLastTradePriceOnly'] - $item['RawYesterdayTradePriceOnly'], 4);
$item['SymbolTrimmed'] = explode('.', $item['Symbol'])[0];
$item = $this->decorateWithReplacements($item);
break;
}
// Parse the result and add it to our data array
$dataProvider->addItem($item);
$dataProvider->setIsHandled();
} catch (InvalidArgumentException $invalidArgumentException) {
$this->getLogger()->error('Invalid symbol ' . $symbol . ', e: ' . $invalidArgumentException->getMessage());
throw new InvalidArgumentException(__('Invalid symbol ' . $symbol), 'items');
}
}
}
/**
* Call Alpha Vantage API to get Stocks data, different endpoint depending on the paidPlan
* cache results for cachePeriod defined in the Connector
*
* @param string $symbol
* @param ?int $isPaidPlan
* @return array
* @throws GeneralException
*/
protected function getStockQuote(string $symbol, ?int $isPaidPlan): array
{
try {
$cache = $this->getPool()->getItem('/widget/stock/api_'.md5($symbol));
$cache->setInvalidationMethod(Invalidation::SLEEP, 5000, 15);
$data = $cache->get();
if ($cache->isMiss()) {
$this->getLogger()->debug('AlphaVantage Connector : getStockQuote is served from the API.');
$request = $this->getClient()->request('GET', 'https://www.alphavantage.co/query', [
'query' => [
'function' => $isPaidPlan === 1 ? 'TIME_SERIES_DAILY_ADJUSTED' : 'TIME_SERIES_DAILY',
'symbol' => $symbol,
'apikey' => $this->getSetting('apiKey')
]
]);
$data = json_decode($request->getBody(), true);
if (!array_key_exists('Time Series (Daily)', $data)) {
$this->getLogger()->debug('getStockQuote Data: ' . var_export($data, true));
throw new InvalidArgumentException(__('Stocks data invalid'), 'Time Series (Daily)');
}
// Cache this and expire in the cache period
$cache->set($data);
$cache->expiresAt(Carbon::now()->addSeconds($this->getSetting('cachePeriod', 14400)));
$this->getPool()->save($cache);
} else {
$this->getLogger()->debug('AlphaVantage Connector : getStockQuote is served from the cache.');
}
return $data;
} catch (GuzzleException $guzzleException) {
throw new GeneralException(
'Guzzle exception getting Stocks data . E = ' .
$guzzleException->getMessage(),
$guzzleException->getCode(),
$guzzleException
);
}
}
/**
* Replacements shared between Stocks and Currencies
*
* @param array $item
* @return array
*/
private function decorateWithReplacements(array $item): array
{
if (($item['Change'] == null || $item['LastTradePriceOnly'] == null)) {
$item['ChangePercentage'] = '0';
} else {
// Calculate the percentage dividing the change by the ( previous value minus the change )
$percentage = $item['Change'] / ( $item['LastTradePriceOnly'] - $item['Change'] );
// Convert the value to percentage and round it
$item['ChangePercentage'] = round($percentage*100, 2);
}
if (($item['Change'] != null && $item['LastTradePriceOnly'] != null)) {
if ($item['Change'] > 0) {
$item['ChangeIcon'] = 'up-arrow';
$item['ChangeStyle'] = 'value-up';
} else if ($item['Change'] < 0) {
$item['ChangeIcon'] = 'down-arrow';
$item['ChangeStyle'] = 'value-down';
}
} else {
$item['ChangeStyle'] = 'value-equal';
$item['ChangeIcon'] = 'right-arrow';
}
return $item;
}
/**
* Get Currencies data from Alpha Vantage, parse it and add to dataProvider
*
* @param DataProviderInterface $dataProvider
* @return void
* @throws InvalidArgumentException
*/
private function getCurrenciesResults(DataProviderInterface $dataProvider): void
{
// What items/base currencies are we interested in?
$items = $dataProvider->getProperty('items');
$base = $dataProvider->getProperty('base');
if (empty($items) || empty($base)) {
$this->getLogger()->error(
'Missing Items for Currencies Module with WidgetId ' .
$dataProvider->getWidgetId()
);
throw new InvalidArgumentException(
__('Missing Items for Currencies Module. Please provide items in order to proceed.'),
'items'
);
}
// Does this require a reversed conversion?
$reverseConversion = ($dataProvider->getProperty('reverseConversion', 0) == 1);
// Is this paid plan?
$isPaidPlan = ($this->getSetting('isPaidPlan', 0) == 1);
// Parse items out into an array
$items = array_map('trim', explode(',', $items));
// Ensure base isn't also in the items list (Currencies)
if (in_array($base, $items)) {
$this->getLogger()->error(
'Invalid Currencies: Base "' . $base . '" also included in Items for ' .
'Currencies Module with WidgetId ' . $dataProvider->getWidgetId()
);
throw new InvalidArgumentException(
__('Base currency must not be included in the Currencies list. Please remove it and try again.'),
'items'
);
}
// Each item we want is a call to the results API
try {
foreach ($items as $currency) {
// Remove the multiplier if there's one (this is handled when we substitute the results into
// the template)
$currency = explode('|', $currency)[0];
// Do we need to reverse the from/to currency for this comparison?
$result = $reverseConversion
? $this->getCurrencyExchangeRate($currency, $base, $isPaidPlan)
: $this->getCurrencyExchangeRate($base, $currency, $isPaidPlan);
$this->getLogger()->debug(
'AlphaVantage Connector : getCurrenciesResults are: ' .
var_export($result, true)
);
if ($isPaidPlan) {
$item = [
'time' => $result['Realtime Currency Exchange Rate']['6. Last Refreshed'],
'ToName' => $result['Realtime Currency Exchange Rate']['3. To_Currency Code'],
'FromName' => $result['Realtime Currency Exchange Rate']['1. From_Currency Code'],
'Bid' => round($result['Realtime Currency Exchange Rate']['5. Exchange Rate'], 4),
'Ask' => round($result['Realtime Currency Exchange Rate']['5. Exchange Rate'], 4),
'LastTradePriceOnly' => round($result['Realtime Currency Exchange Rate']['5. Exchange Rate'], 4),
'RawLastTradePriceOnly' => $result['Realtime Currency Exchange Rate']['5. Exchange Rate'],
'TimeZone' => $result['Realtime Currency Exchange Rate']['7. Time Zone'],
];
} else {
$item = [
'time' => $result['Meta Data']['5. Last Refreshed'],
'ToName' => $result['Meta Data']['3. To Symbol'],
'FromName' => $result['Meta Data']['2. From Symbol'],
'Bid' => round(array_values($result['Time Series FX (Daily)'])[0]['1. open'], 4),
'Ask' => round(array_values($result['Time Series FX (Daily)'])[0]['1. open'], 4),
'LastTradePriceOnly' => round(array_values($result['Time Series FX (Daily)'])[0]['1. open'], 4),
'RawLastTradePriceOnly' => array_values($result['Time Series FX (Daily)'])[0]['1. open'],
'TimeZone' => $result['Meta Data']['6. Time Zone'],
];
}
// Set the name/currency to be the full name including the base currency
$item['Name'] = $item['FromName'] . '/' . $item['ToName'];
$currencyName = ($reverseConversion) ? $item['FromName'] : $item['ToName'];
$item['NameShort'] = $currencyName;
// work out the change when compared to the previous day
// We need to get the prior day for this pair only (reversed)
$priorDay = $reverseConversion
? $this->getCurrencyPriorDay($currency, $base, $isPaidPlan)
: $this->getCurrencyPriorDay($base, $currency, $isPaidPlan);
/*$this->getLog()->debug('Percentage change requested, prior day is '
. var_export($priorDay['Time Series FX (Daily)'], true));*/
$priorDay = count($priorDay['Time Series FX (Daily)']) < 2
? ['1. open' => 1]
: array_values($priorDay['Time Series FX (Daily)'])[1];
$item['YesterdayTradePriceOnly'] = $priorDay['1. open'];
$item['Change'] = $item['RawLastTradePriceOnly'] - $item['YesterdayTradePriceOnly'];
$item = $this->decorateWithReplacements($item);
$this->getLogger()->debug(
'AlphaVantage Connector : Parsed getCurrenciesResults are: ' .
var_export($item, true)
);
$dataProvider->addItem($item);
$dataProvider->setIsHandled();
}
} catch (GeneralException $requestException) {
$this->getLogger()->error('Problem getting currency information. E = ' . $requestException->getMessage());
$this->getLogger()->debug($requestException->getTraceAsString());
return;
}
}
/**
* Call Alpha Vantage API to get Currencies data, different endpoint depending on the paidPlan
* cache results for cachePeriod defined on the Connector
*
* @param string $fromCurrency
* @param string $toCurrency
* @param bool $isPaidPlan
* @return mixed
* @throws GeneralException
* @throws InvalidArgumentException
*/
private function getCurrencyExchangeRate(string $fromCurrency, string $toCurrency, bool $isPaidPlan)
{
try {
$cache = $this->getPool()->getItem('/widget/currency/' . md5($fromCurrency . $toCurrency . $isPaidPlan));
$cache->setInvalidationMethod(Invalidation::SLEEP, 5000, 15);
$data = $cache->get();
if ($cache->isMiss()) {
$this->getLogger()->debug('AlphaVantage Connector : getCurrencyExchangeRate is served from the API.');
// Use a different function depending on whether we have a paid plan or not.
if ($isPaidPlan) {
$query = [
'function' => 'CURRENCY_EXCHANGE_RATE',
'from_currency' => $fromCurrency,
'to_currency' => $toCurrency,
];
} else {
$query = [
'function' => 'FX_DAILY',
'from_symbol' => $fromCurrency,
'to_symbol' => $toCurrency,
];
}
$query['apikey'] = $this->getSetting('apiKey');
$request = $this->getClient()->request('GET', 'https://www.alphavantage.co/query', [
'query' => $query
]);
$data = json_decode($request->getBody(), true);
if ($isPaidPlan) {
if (!array_key_exists('Realtime Currency Exchange Rate', $data)) {
$this->getLogger()->debug('Data: ' . var_export($data, true));
throw new InvalidArgumentException(
__('Currency data invalid'),
'Realtime Currency Exchange Rate'
);
}
} else {
if (!array_key_exists('Meta Data', $data)) {
$this->getLogger()->debug('Data: ' . var_export($data, true));
throw new InvalidArgumentException(__('Currency data invalid'), 'Meta Data');
}
if (!array_key_exists('Time Series FX (Daily)', $data)) {
$this->getLogger()->debug('Data: ' . var_export($data, true));
throw new InvalidArgumentException(__('Currency data invalid'), 'Time Series FX (Daily)');
}
}
// Cache this and expire in the cache period
$cache->set($data);
$cache->expiresAt(Carbon::now()->addSeconds($this->getSetting('cachePeriod', 14400)));
$this->getPool()->save($cache);
} else {
$this->getLogger()->debug('AlphaVantage Connector : getCurrencyExchangeRate is served from the cache.');
}
return $data;
} catch (GuzzleException $guzzleException) {
throw new GeneralException(
'Guzzle exception getting currency exchange rate. E = ' .
$guzzleException->getMessage(),
$guzzleException->getCode(),
$guzzleException
);
}
}
/**
* Call Alpha Vantage API to get currencies data, cache results for a day
*
* @param $fromCurrency
* @param $toCurrency
* @param $isPaidPlan
* @return mixed
* @throws GeneralException
* @throws InvalidArgumentException
*/
private function getCurrencyPriorDay($fromCurrency, $toCurrency, $isPaidPlan)
{
if ($isPaidPlan) {
$key = md5($fromCurrency . $toCurrency . Carbon::yesterday()->format('Y-m-d') . '1');
} else {
$key = md5($fromCurrency . $toCurrency . '0');
}
try {
$cache = $this->getPool()->getItem('/widget/Currencies/' . $key);
$cache->setInvalidationMethod(Invalidation::SLEEP, 5000, 15);
$data = $cache->get();
if ($cache->isMiss()) {
$this->getLogger()->debug('AlphaVantage Connector : getPriorDay is served from the API.');
// Use a web request
$request = $this->getClient()->request('GET', 'https://www.alphavantage.co/query', [
'query' => [
'function' => 'FX_DAILY',
'from_symbol' => $fromCurrency,
'to_symbol' => $toCurrency,
'apikey' => $this->getSetting('apiKey')
]
]);
$data = json_decode($request->getBody(), true);
if (!array_key_exists('Meta Data', $data)) {
$this->getLogger()->debug('Data: ' . var_export($data, true));
throw new InvalidArgumentException(__('Currency data invalid'), 'Meta Data');
}
if (!array_key_exists('Time Series FX (Daily)', $data)) {
$this->getLogger()->debug('Data: ' . var_export($data, true));
throw new InvalidArgumentException(__('Currency data invalid'), 'Time Series FX (Daily)');
}
// Cache this and expire tomorrow (results are valid for the entire day regardless of settings)
$cache->set($data);
$cache->expiresAt(Carbon::tomorrow());
$this->getPool()->save($cache);
} else {
$this->getLogger()->debug('AlphaVantage Connector : getPriorDay is served from the cache.');
}
return $data;
} catch (GuzzleException $guzzleException) {
throw new GeneralException(
'Guzzle exception getting currency exchange rate. E = ' .
$guzzleException->getMessage(),
$guzzleException->getCode(),
$guzzleException
);
}
}
}

View 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'),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* 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 GuzzleHttp\Client;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Stash\Interfaces\PoolInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Xibo\Service\JwtServiceInterface;
use Xibo\Service\PlayerActionServiceInterface;
use Xibo\Support\Sanitizer\SanitizerInterface;
/**
* Connector Interface
*/
interface ConnectorInterface
{
public function setFactories(ContainerInterface $container): ConnectorInterface;
public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface;
public function useLogger(LoggerInterface $logger): ConnectorInterface;
public function useSettings(array $settings, bool $isProvider = true): ConnectorInterface;
public function usePool(PoolInterface $pool): ConnectorInterface;
public function useHttpOptions(array $httpOptions): ConnectorInterface;
public function useJwtService(JwtServiceInterface $jwtService): ConnectorInterface;
public function usePlayerActionService(PlayerActionServiceInterface $playerActionService): ConnectorInterface;
public function getClient(): Client;
public function getSourceName(): string;
public function getTitle(): string;
public function getDescription(): string;
public function getThumbnail(): string;
public function getSetting($setting, $default = null);
public function isProviderSetting($setting): bool;
public function getSettingsFormTwig(): string;
public function getSettingsFormJavaScript(): string;
public function processSettingsForm(SanitizerInterface $params, array $settings): array;
}

View File

@@ -0,0 +1,202 @@
<?php
/*
* Copyright (C) 2023 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 GuzzleHttp\Client;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Stash\Interfaces\PoolInterface;
use Xibo\Service\JwtServiceInterface;
use Xibo\Service\PlayerActionServiceInterface;
/**
* Connector trait to assist with basic scaffolding and utility methods.
* we recommend all connectors use this trait.
*/
trait ConnectorTrait
{
/** @var \Psr\Log\LoggerInterface */
private $logger;
/** @var array */
private $settings = [];
/** @var array The keys for all provider settings */
private $providerSettings = [];
/** @var PoolInterface|null */
private $pool;
/** @var array */
private $httpOptions = [];
/** @var array */
private $keys = [];
/** @var JwtServiceInterface */
private $jwtService;
/** @var PlayerActionServiceInterface */
private $playerActionService;
/**
* @param \Psr\Log\LoggerInterface $logger
* @return \Xibo\Connector\ConnectorInterface
*/
public function useLogger(LoggerInterface $logger): ConnectorInterface
{
$this->logger = $logger;
return $this;
}
/**
* @return \Psr\Log\LoggerInterface|\Psr\Log\NullLogger
*/
private function getLogger(): LoggerInterface
{
if ($this->logger === null) {
return new NullLogger();
}
return $this->logger;
}
/**
* @param array $settings
* @param bool $provider
* @return ConnectorInterface
*/
public function useSettings(array $settings, bool $provider = false): ConnectorInterface
{
if ($provider) {
$this->providerSettings = array_keys($settings);
}
$this->settings = array_merge($this->settings, $settings);
return $this;
}
/**
* @param $setting
* @return bool
*/
public function isProviderSetting($setting): bool
{
return in_array($setting, $this->providerSettings);
}
/**
* @param $setting
* @param null $default
* @return string|null
*/
public function getSetting($setting, $default = null)
{
$this->logger->debug('getSetting: ' . $setting);
if (!array_key_exists($setting, $this->settings)) {
$this->logger->debug('getSetting: ' . $setting . ' not present.');
return $default;
}
return $this->settings[$setting] ?: $default;
}
/**
* @param \Stash\Interfaces\PoolInterface $pool
* @return \Xibo\Connector\ConnectorInterface
*/
public function usePool(PoolInterface $pool): ConnectorInterface
{
$this->pool = $pool;
return $this;
}
/**
* @return \Stash\Interfaces\PoolInterface
*/
private function getPool(): PoolInterface
{
return $this->pool;
}
/**
* @param array $options
* @return \Xibo\Connector\ConnectorInterface
*/
public function useHttpOptions(array $options): ConnectorInterface
{
$this->httpOptions = $options;
return $this;
}
public function useJwtService(JwtServiceInterface $jwtService): ConnectorInterface
{
$this->jwtService = $jwtService;
return $this;
}
protected function getJwtService(): JwtServiceInterface
{
return $this->jwtService;
}
public function usePlayerActionService(PlayerActionServiceInterface $playerActionService): ConnectorInterface
{
$this->playerActionService = $playerActionService;
return $this;
}
protected function getPlayerActionService(): PlayerActionServiceInterface
{
return $this->playerActionService;
}
public function setFactories($container): ConnectorInterface
{
return $this;
}
public function getSettingsFormJavaScript(): string
{
return '';
}
/**
* Get an HTTP client with the default proxy settings, etc
* @return \GuzzleHttp\Client
*/
public function getClient(): Client
{
return new Client($this->httpOptions);
}
/**
* Return a layout preview URL for the provided connector token
* this can be used in a data request and is decorated by the previewing function.
* @param string $token
* @return string
*/
public function getTokenUrl(string $token): string
{
return '[[connector='.$token.']]';
}
}

View File

@@ -0,0 +1,53 @@
<?php
/*
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* 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;
/**
* Interface for handling the DataConnectorScriptRequestEvent.
*
* Provides methods for connectors to supply their data connector JS code.
*
* These methods should be used together:
* - Use getConnectorId() to retrieve the unique identifier of the connector provided in the event.
* - Check if the connector's ID matches the ID provided in the event.
* - If the IDs match, use setScript() to provide the JavaScript code for the data connector.
*
* This ensures that the correct script is supplied by the appropriate connector.
*/
interface DataConnectorScriptProviderInterface
{
/**
* Get the unique identifier of the connector that is selected as the data source for the dataset.
*
* @return string
*/
public function getConnectorId(): string;
/**
* Set the data connector JavaScript code provided by the connector. Requires real time.
*
* @param string $script JavaScript code
* @return void
*/
public function setScript(string $script): void;
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* 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 InvalidArgumentException;
/**
* Interface for handling the DataConnectorSourceRequestEvent.
*
* Registers connectors that provide data connector JavaScript (JS).
*/
interface DataConnectorSourceProviderInterface
{
/**
* Adds/Registers a connector, that would provide a data connector JS, to the event.
* Implementations should use $this->getSourceName() as the $id and $this->getTitle() as the $name.
*
* @param string $id
* @param string $name
* @throws InvalidArgumentException if a duplicate ID or name is found.
*/
public function addDataConnectorSource(string $id, string $name): void;
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* Copyright (C) 2025 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* 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;
/**
* Connector Interface for Emergency Alerts
*/
interface EmergencyAlertInterface
{
/**
* Represents the status when there is at least one alert of type "Actual".
*/
public const ACTUAL_ALERT = 'actual_alerts';
/**
* Represents the status when there are no alerts of any type.
*/
public const NO_ALERT = 'no_alerts';
/**
* Represents the status when there is at least one test alert
* (e.g., Exercise, System, Test, Draft).
*/
public const TEST_ALERT = 'test_alerts';
}

View File

@@ -0,0 +1,418 @@
<?php
/*
* Copyright (C) 2025 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 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 National Weather Alert (NWS) - Atom feed data
*/
class NationalWeatherServiceConnector implements ConnectorInterface, EmergencyAlertInterface
{
use ConnectorTrait;
/** @var DOMDocument */
protected DOMDocument $atomFeedXML;
/** @var DOMElement */
protected DOMElement $feedNode;
/** @var DOMElement */
protected DOMElement $entryNode;
/** @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 'national-weather-service-connector';
}
public function getTitle(): string
{
return 'National Weather Service Connector';
}
public function getDescription(): string
{
return 'National Weather Service (NWS)';
}
public function getThumbnail(): string
{
return 'theme/default/img/connectors/xibo-nws.png';
}
public function getSettingsFormTwig(): string
{
return 'national-weather-service-form-settings';
}
public function processSettingsForm(SanitizerInterface $params, array $settings): array
{
if (!$this->isProviderSetting('atomFeedUri')) {
$settings['atomFeedUri'] = $params->getString('atomFeedUri');
}
return $settings;
}
/**
* If the requested dataSource is national-weather-service, 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() === 'national-weather-service') {
if (empty($this->getSetting('atomFeedUri'))) {
$this->getLogger()->debug('onDataRequest: National Weather Service Connector not configured.');
return;
}
$event->stopPropagation();
try {
// Set cache expiry date to 3 minutes from now
$cacheExpire = Carbon::now()->addMinutes(3);
// Fetch the Atom Feed XML content
$xmlContent = $this->getFeedFromUrl($event->getDataProvider(), $cacheExpire);
// Initialize DOMDocument and load the XML content
$this->atomFeedXML = new DOMDocument();
$this->atomFeedXML->loadXML($xmlContent);
// Ensure the root element is <feed>
$feedNode = $this->atomFeedXML->getElementsByTagName('feed')->item(0);
if ($feedNode instanceof DOMElement) {
$this->feedNode = $feedNode;
} else {
throw new \Exception('The root <feed> element is missing.');
}
// Get all <entry> nodes within the <feed> element
$entryNodes = $this->feedNode->getElementsByTagName('entry');
// Are there any?
if ($entryNodes->length) {
// Process and initialize Atom Feed data
$this->processAtomFeedData($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();
// Define priority arrays for status (higher priority = lower index)
$statusPriority = ['Actual', 'Exercise', 'System', 'Test', 'Draft'];
$highestStatus = null;
// Iterate through each <entry> node to find the highest-priority status
foreach ($entryNodes as $entryNode) {
$this->entryNode = $entryNode;
// Get the status for the current entry
$entryStatus = $this->getEntryData('status');
// Check if the current status has a higher priority
if ($entryStatus !== null && (
$highestStatus === null ||
array_search($entryStatus, $statusPriority) < array_search($highestStatus, $statusPriority)
)) {
$highestStatus = $entryStatus;
}
}
$capStatus = $highestStatus;
$category = 'Met';
} else {
$capStatus = 'No Alerts';
$category = '';
$event->getDataProvider()->addError(__('No alerts are available for the selected area at the moment.'));//phpcs:ignore
}
// 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 schedule criteria update
$action = new ScheduleCriteriaUpdateAction();
$action->setCriteriaUpdates([
['metric' => 'emergency_alert_status', 'value' => $status, 'ttl' => 60],
['metric' => 'emergency_alert_category', 'value' => $category, 'ttl' => 60]
]);
// 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());
}
}
}
/**
* Get and process the NWS Atom Feed data
*
* @throws Exception
*/
private function processAtomFeedData(DataProviderInterface $dataProvider): void
{
// Array to store configuration data
$config = [];
// Initialize configuration data
$config['status'] = $dataProvider->getProperty('status');
$config['msgType'] = $dataProvider->getProperty('msgType');
$config['urgency'] = $dataProvider->getProperty('urgency');
$config['severity'] = $dataProvider->getProperty('severity');
$config['certainty'] = $dataProvider->getProperty('certainty');
// Get all <entry> nodes within the <feed> element
$entryNodes = $this->feedNode->getElementsByTagName('entry');
// Iterate through each <entry> node
foreach ($entryNodes as $entryNode) {
$this->entryNode = $entryNode;
// Retrieve specific values from the CAP XML for filtering
$status = $this->getEntryData('status');
$msgType = $this->getEntryData('msgType');
$urgency = $this->getEntryData('urgency');
$severity = $this->getEntryData('severity');
$certainty = $this->getEntryData('certainty');
// Check if the retrieved CAP data matches the configuration filters
if (!$this->matchesFilter($status, $config['status']) ||
!$this->matchesFilter($msgType, $config['msgType']) ||
!$this->matchesFilter($urgency, $config['urgency']) ||
!$this->matchesFilter($severity, $config['severity']) ||
!$this->matchesFilter($certainty, $config['certainty'])
) {
continue;
}
// Array to store CAP values
$cap = [];
// Initialize CAP values
$cap['source'] = $this->getEntryData('source');
$cap['note'] = $this->getEntryData('note');
$cap['event'] = $this->getEntryData('event');
$cap['urgency'] = $this->getEntryData('urgency');
$cap['severity'] = $this->getEntryData('severity');
$cap['certainty'] = $this->getEntryData('certainty');
$cap['dateTimeEffective'] = $this->getEntryData('effective');
$cap['dateTimeOnset'] = $this->getEntryData('onset');
$cap['dateTimeExpires'] = $this->getEntryData('expires');
$cap['headline'] = $this->getEntryData('headline');
$cap['description'] = $this->getEntryData('summary');
$cap['instruction'] = $this->getEntryData('instruction');
$cap['contact'] = $this->getEntryData('contact');
$cap['areaDesc'] = $this->getEntryData('areaDesc');
// Add CAP data to data provider
$dataProvider->addItem($cap);
}
}
/**
* Fetches the National Weather Service's Atom Feed XML data from the Atom Feed URL provided by the connector.
*
* @param DataProviderInterface $dataProvider
* @param Carbon $cacheExpiresAt
*
* @return string|null
* @throws GuzzleException
*/
private function getFeedFromUrl(DataProviderInterface $dataProvider, Carbon $cacheExpiresAt): string|null
{
$atomFeedUri = $this->getSetting('atomFeedUri');
$area = $dataProvider->getProperty('area');
// Construct the Atom feed url
if (empty($area)) {
$url = $atomFeedUri;
} else {
$url = $atomFeedUri . '?area=' . $area;
}
$cache = $this->pool->getItem('/national-weather-service/alerts/' . md5($url));
$data = $cache->get();
if ($cache->isMiss()) {
$cache->lock();
$this->getLogger()->debug('Getting alerts from National Weather Service Atom feed');
$httpOptions = [
'timeout' => 20, // Wait no more than 20 seconds
];
try {
// Make a GET request to the Atom Feed URL using Guzzle HTTP client with defined options
$response = $dataProvider
->getGuzzleClient($httpOptions)
->get($url);
$this->getLogger()->debug('NWS Atom Feed uri: ' . $url . ' 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 NWS Alert data fetching
$this->getLogger()->error('Unable to reach the NWS Atom feed URL: '
. $url . ' Error: ' . $e->getMessage());
// Throw a more specific exception message
$dataProvider->addError(__('Failed to retrieve NWS alerts from specified Atom Feed URL.'));
}
} else {
$this->getLogger()->debug('Getting NWS Alert data from cache');
}
return $data;
}
/**
* Get the value of a specified tag from the current <entry> node.
*
* @param string $tagName
* @return string|null
*/
private function getEntryData(string $tagName): ?string
{
// Ensure the tag exists within the provided <entry> node
$node = $this->entryNode->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 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;
}
/**
* @param ScheduleCriteriaRequestInterface $event
* @return void
* @throws ConfigurationException
*/
public function onScheduleCriteriaRequest(ScheduleCriteriaRequestInterface $event): void
{
// Initialize Emergency Alerts schedule criteria parameters but with limited category
$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', [
'Met' => __('Met')
]);
}
}

View File

@@ -0,0 +1,951 @@
<?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 GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Str;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Xibo\Event\ScheduleCriteriaRequestEvent;
use Xibo\Event\ScheduleCriteriaRequestInterface;
use Xibo\Event\WidgetDataRequestEvent;
use Xibo\Event\XmdsWeatherRequestEvent;
use Xibo\Support\Exception\ConfigurationException;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Sanitizer\SanitizerInterface;
use Xibo\Widget\DataType\Forecast;
use Xibo\Widget\Provider\DataProviderInterface;
/**
* A connector to get data from the Open Weather Map API for use by the Weather Widget
*/
class OpenWeatherMapConnector implements ConnectorInterface
{
use ConnectorTrait;
private $apiUrl = 'https://api.openweathermap.org/data/';
private $forecastCurrent = '2.5/weather';
private $forecast3Hourly = '2.5/forecast';
private $forecastDaily = '2.5/forecast/daily';
private $forecastCombinedV3 = '3.0/onecall';
/** @var string */
protected $timezone;
/** @var \Xibo\Widget\DataType\Forecast */
protected $currentDay;
public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
{
$dispatcher->addListener(WidgetDataRequestEvent::$NAME, [$this, 'onDataRequest']);
$dispatcher->addListener(ScheduleCriteriaRequestEvent::$NAME, [$this, 'onScheduleCriteriaRequest']);
$dispatcher->addListener(XmdsWeatherRequestEvent::$NAME, [$this, 'onXmdsWeatherRequest']);
return $this;
}
public function getSourceName(): string
{
return 'openweathermap';
}
public function getTitle(): string
{
return 'Open Weather Map';
}
public function getDescription(): string
{
return 'Get Weather data from Open Weather Map API';
}
public function getThumbnail(): string
{
return 'theme/default/img/connectors/owm.png';
}
public function getSettingsFormTwig(): string
{
return 'openweathermap-form-settings';
}
public function processSettingsForm(SanitizerInterface $params, array $settings): array
{
if (!$this->isProviderSetting('owmApiKey')) {
$settings['owmApiKey'] = $params->getString('owmApiKey');
$settings['owmIsPaidPlan'] = $params->getCheckbox('owmIsPaidPlan');
$settings['cachePeriod'] = $params->getInt('cachePeriod');
$settings['xmdsCachePeriod'] = $params->getInt('xmdsCachePeriod');
}
return $settings;
}
/**
* If the requested dataSource is forecastio, get the data, process it and add to dataProvider
*
* @param WidgetDataRequestEvent $event
* @return void
*/
public function onDataRequest(WidgetDataRequestEvent $event)
{
if ($event->getDataProvider()->getDataSource() === 'forecastio') {
if (empty($this->getSetting('owmApiKey'))) {
$this->getLogger()->debug('onDataRequest: Open Weather Map not configured.');
return;
}
$event->stopPropagation();
if ($this->isProviderSetting('apiUrl')) {
$this->apiUrl = $this->getSetting('apiUrl');
}
try {
$this->getWeatherData($event->getDataProvider());
// If we've got data, then set our cache period.
$event->getDataProvider()->setCacheTtl($this->getSetting('cachePeriod', 3600));
$event->getDataProvider()->setIsHandled();
} catch (\Exception $exception) {
$this->getLogger()->error('onDataRequest: Failed to get results. e = ' . $exception->getMessage());
$event->getDataProvider()->addError(__('Unable to get weather results.'));
}
}
}
/**
* Get a combined forecast
* @throws \Xibo\Support\Exception\GeneralException
*/
private function getWeatherData(DataProviderInterface $dataProvider)
{
// Convert units to an acceptable format
$units = in_array($dataProvider->getProperty('units', 'auto'), ['auto', 'us', 'uk2']) ? 'imperial' : 'metric';
// Temperature and Wind Speed Unit Mappings
$unit = $this->getUnit($dataProvider->getProperty('units'));
if ($dataProvider->getProperty('useDisplayLocation') == 0) {
$providedLat = $dataProvider->getProperty('latitude', $dataProvider->getDisplayLatitude());
$providedLon = $dataProvider->getProperty('longitude', $dataProvider->getDisplayLongitude());
} else {
$providedLat = $dataProvider->getDisplayLatitude();
$providedLon = $dataProvider->getDisplayLongitude();
}
// Build the URL
$url = '?lat=' . $providedLat
. '&lon=' . $providedLon
. '&units=' . $units
. '&lang=' . $dataProvider->getProperty('lang', 'en')
. '&appid=[API_KEY]';
// Cache expiry date
$cacheExpire = Carbon::now()->addSeconds($this->getSetting('cachePeriod'));
if ($this->getSetting('owmIsPaidPlan') ?? 0 == 1) {
// We build our data from multiple API calls
// Current data first.
$data = $this->queryApi($this->apiUrl . $this->forecastCurrent . $url, $cacheExpire);
$data['current'] = $this->parseCurrentIntoFormat($data);
// initialize timezone
$timezoneOffset = (int)$data['timezone'];
// Calculate the number of whole hours in the offset
$offsetHours = floor($timezoneOffset / 3600);
// Calculate the remaining minutes after extracting the whole hours
$offsetMinutes = ($timezoneOffset % 3600) / 60;
// Determine the sign of the offset (positive or negative)
$sign = $offsetHours < 0 ? '-' : '+';
// Ensure the format is as follows: +/-hh:mm
$formattedOffset = sprintf("%s%02d:%02d", $sign, abs($offsetHours), abs($offsetMinutes));
// Get the timezone name
$this->timezone = (new \DateTimeZone($formattedOffset))->getName();
// Pick out the country
$country = $data['sys']['country'] ?? null;
$this->getLogger()->debug('Trying to determine units for Country: ' . $country);
// If we don't have a unit, then can we base it on the timezone we got back?
if ($dataProvider->getProperty('units', 'auto') === 'auto' && $country !== null) {
// Pick out some countries to set the units
if ($country === 'GB') {
$unit = $this->getUnit('uk2');
} else if ($country === 'US') {
$unit = $this->getUnit('us');
} else if ($country === 'CA') {
$unit = $this->getUnit('ca');
} else {
$unit = $this->getUnit('si');
}
}
// Then the 16 day forecast API, which we will cache a day
$data['daily'] = $this->queryApi(
$this->apiUrl . $this->forecastDaily . $url,
$cacheExpire->copy()->addDay()->startOfDay()
)['list'];
} else {
// We use one call API 3.0
$data = $this->queryApi($this->apiUrl . $this->forecastCombinedV3 . $url, $cacheExpire);
$this->timezone = $data['timezone'];
// Country based on timezone (this is harder than using the real country)
if ($dataProvider->getProperty('units', 'auto') === 'auto') {
if (Str::startsWith($this->timezone, 'America')) {
$unit = $this->getUnit('us');
} else if ($this->timezone === 'Europe/London') {
$unit = $this->getUnit('uk2');
} else {
$unit = $this->getUnit('si');
}
}
}
// Using units:
$this->getLogger()->debug('Using units: ' . json_encode($unit));
$forecasts = [];
// Parse into our forecast.
// Load this data into our objects
$this->currentDay = new Forecast();
$this->currentDay->temperatureUnit = $unit['tempUnit'] ?: 'C';
$this->currentDay->windSpeedUnit = $unit['windUnit'] ?: 'KPH';
$this->currentDay->visibilityDistanceUnit = $unit['visibilityUnit'] ?: 'km';
$this->currentDay->location = $data['name'] ?? '';
$this->processItemIntoDay($this->currentDay, $data['current'], $units, true);
$countForecast = 0;
// Process each day into a forecast
foreach ($data['daily'] as $dayItem) {
// Skip first item as this is the currentDay
if ($countForecast++ === 0) {
continue;
}
$day = new Forecast();
$day->temperatureUnit = $this->currentDay->temperatureUnit;
$day->windSpeedUnit = $this->currentDay->windSpeedUnit;
$day->visibilityDistanceUnit = $this->currentDay->visibilityDistanceUnit;
$day->location = $this->currentDay->location;
$this->processItemIntoDay($day, $dayItem, $units);
$forecasts[] = $day;
}
// Enhance the currently with the high/low from the first daily forecast
$this->currentDay->temperatureHigh = $forecasts[0]->temperatureHigh;
$this->currentDay->temperatureMaxRound = $forecasts[0]->temperatureMaxRound;
$this->currentDay->temperatureLow = $forecasts[0]->temperatureLow;
$this->currentDay->temperatureMinRound = $forecasts[0]->temperatureMinRound;
$this->currentDay->temperatureMorning = $forecasts[0]->temperatureMorning;
$this->currentDay->temperatureMorningRound = $forecasts[0]->temperatureMorningRound;
$this->currentDay->temperatureNight = $forecasts[0]->temperatureNight;
$this->currentDay->temperatureNightRound = $forecasts[0]->temperatureNightRound;
$this->currentDay->temperatureEvening = $forecasts[0]->temperatureEvening;
$this->currentDay->temperatureEveningRound = $forecasts[0]->temperatureEveningRound;
$this->currentDay->temperatureMean = $forecasts[0]->temperatureMean;
$this->currentDay->temperatureMeanRound = $forecasts[0]->temperatureMeanRound;
if ($dataProvider->getProperty('dayConditionsOnly', 0) == 1) {
// Swap the night icons for their day equivalents
$this->currentDay->icon = str_replace('-night', '', $this->currentDay->icon);
$this->currentDay->wicon = str_replace('-night', '', $this->currentDay->wicon);
}
$dataProvider->addItem($this->currentDay);
if (count($forecasts) > 0) {
foreach ($forecasts as $forecast) {
$dataProvider->addItem($forecast);
}
}
$dataProvider->addOrUpdateMeta('Attribution', 'Powered by OpenWeather');
}
/**
* @param string $url
* @param Carbon $cacheExpiresAt
* @return array
* @throws \Xibo\Support\Exception\GeneralException
*/
private function queryApi(string $url, Carbon $cacheExpiresAt): array
{
$cache = $this->pool->getItem('/weather/owm/' . md5($url));
$data = $cache->get();
if ($cache->isMiss()) {
$cache->lock();
$this->getLogger()->debug('Getting Forecast from API');
$url = str_replace('[API_KEY]', $this->getSetting('owmApiKey'), $url);
try {
$response = $this->getClient()->get($url);
// Success?
if ($response->getStatusCode() != 200) {
throw new GeneralException('Non-200 response from Open Weather Map');
}
// Parse out header and body
$data = json_decode($response->getBody(), true);
// Cache
$cache->set($data);
$cache->expiresAt($cacheExpiresAt);
$this->pool->saveDeferred($cache);
} catch (RequestException $e) {
$this->getLogger()->error('Unable to reach Open Weather Map API: '
. str_replace($this->getSetting('owmApiKey'), '[API_KEY]', $e->getMessage()));
throw new GeneralException('API responded with an error.');
}
} else {
$this->getLogger()->debug('Getting Forecast from cache');
}
return $data;
}
/**
* Parse the response from the current API into the format provided by the onecall API
* this means easier processing down the line
* @param array $source
* @return array
*/
private function parseCurrentIntoFormat(array $source): array
{
return [
'timezone' => $source['timezone'],
'dt' => $source['dt'],
'sunrise' => $source['sys']['sunrise'],
'sunset' => $source['sys']['sunset'],
'temp' => $source['main']['temp'],
'feels_like' => $source['main']['feels_like'],
'pressure' => $source['main']['pressure'],
'humidity' => $source['main']['humidity'],
'dew_point' => null,
'uvi' => null,
'clouds' => $source['clouds']['all'],
'visibility' => $source['visibility'] ?? 0,
'wind_speed' => $source['wind']['speed'],
'wind_deg' => $source['wind']['deg'] ?? 0,
'weather' => $source['weather'],
];
}
/**
* @param \Xibo\Weather\Forecast $day
* @param array $item
* @param $requestUnit
* @param bool $isCurrent
*/
private function processItemIntoDay($day, $item, $requestUnit, $isCurrent = false)
{
$day->time = $item['dt'];
$day->sunRise = $item['sunrise'];
$day->sunSet = $item['sunset'];
$day->summary = ucfirst($item['weather'][0]['description']);
// Temperature
// imperial = F
// metric = C
if ($isCurrent) {
$day->temperature = $item['temp'];
$day->apparentTemperature = $item['feels_like'];
$day->temperatureHigh = $day->temperature;
$day->temperatureLow = $day->temperature;
$day->temperatureNight = $day->temperature;
$day->temperatureEvening = $day->temperature;
$day->temperatureMorning = $day->temperature;
} else {
$day->temperature = $item['temp']['day'];
$day->apparentTemperature = $item['feels_like']['day'];
$day->temperatureHigh = $item['temp']['max'] ?? $day->temperature;
$day->temperatureLow = $item['temp']['min'] ?? $day->temperature;
$day->temperatureNight = $item['temp']['night'];
$day->temperatureEvening = $item['temp']['eve'];
$day->temperatureMorning = $item['temp']['morn'];
}
if ($requestUnit === 'metric' && $day->temperatureUnit === 'F') {
// Convert C to F
$day->temperature = ($day->temperature) * 9 / 5 + 32;
$day->apparentTemperature = ($day->apparentTemperature) * 9 / 5 + 32;
$day->temperatureHigh = ($day->temperatureHigh) * 9 / 5 + 32;
$day->temperatureLow = ($day->temperatureLow) * 9 / 5 + 32;
$day->temperatureNight = ($day->temperatureNight) * 9 / 5 + 32;
$day->temperatureEvening = ($day->temperatureEvening) * 9 / 5 + 32;
$day->temperatureMorning = ($day->temperatureMorning) * 9 / 5 + 32;
} else if ($requestUnit === 'imperial' && $day->temperatureUnit === 'C') {
// Convert F to C
$day->temperature = ($day->temperature - 32) * 5 / 9;
$day->apparentTemperature = ($day->apparentTemperature - 32) * 5 / 9;
$day->temperatureHigh = ($day->temperatureHigh - 32) * 5 / 9;
$day->temperatureLow = ($day->temperatureLow - 32) * 5 / 9;
$day->temperatureNight = ($day->temperatureNight - 32) * 5 / 9;
$day->temperatureEvening = ($day->temperatureEvening - 32) * 5 / 9;
$day->temperatureMorning = ($day->temperatureMorning - 32) * 5 / 9;
}
// Work out the mean
$day->temperatureMean = ($day->temperatureHigh + $day->temperatureLow) / 2;
// Round those off
$day->temperatureRound = round($day->temperature, 0);
$day->temperatureNightRound = round($day->temperatureNight, 0);
$day->temperatureMorningRound = round($day->temperatureMorning, 0);
$day->temperatureEveningRound = round($day->temperatureEvening, 0);
$day->apparentTemperatureRound = round($day->apparentTemperature, 0);
$day->temperatureMaxRound = round($day->temperatureHigh, 0);
$day->temperatureMinRound = round($day->temperatureLow, 0);
$day->temperatureMeanRound = round($day->temperatureMean, 0);
// Humidity
$day->humidityPercent = $item['humidity'];
$day->humidity = $day->humidityPercent / 100;
// Pressure
// received in hPa, display in mB
$day->pressure = $item['pressure'] / 100;
// Wind
// metric = meters per second
// imperial = miles per hour
$day->windSpeed = $item['wind_speed'] ?? $item['speed'] ?? null;
$day->windBearing = $item['wind_deg'] ?? $item['deg'] ?? null;
if ($requestUnit === 'metric' && $day->windSpeedUnit !== 'MPS') {
// We have MPS and need to go to something else
if ($day->windSpeedUnit === 'MPH') {
// Convert MPS to MPH
$day->windSpeed = round($day->windSpeed * 2.237, 2);
} else if ($day->windSpeedUnit === 'KPH') {
// Convert MPS to KPH
$day->windSpeed = round($day->windSpeed * 3.6, 2);
}
} else if ($requestUnit === 'imperial' && $day->windSpeedUnit !== 'MPH') {
if ($day->windSpeedUnit === 'MPS') {
// Convert MPH to MPS
$day->windSpeed = round($day->windSpeed / 2.237, 2);
} else if ($day->windSpeedUnit === 'KPH') {
// Convert MPH to KPH
$day->windSpeed = round($day->windSpeed * 1.609344, 2);
}
}
// Wind direction
$day->windDirection = '--';
if ($day->windBearing !== null && $day->windBearing !== 0) {
foreach (self::cardinalDirections() as $dir => $angles) {
if ($day->windBearing >= $angles[0] && $day->windBearing < $angles[1]) {
$day->windDirection = $dir;
break;
}
}
}
// Clouds
$day->cloudCover = $item['clouds'];
// Visibility
// metric = meters
// imperial = meters?
$day->visibility = $item['visibility'] ?? '--';
if ($day->visibility !== '--') {
// Always in meters
if ($day->visibilityDistanceUnit === 'mi') {
// Convert meters to miles
$day->visibility = $day->visibility / 1609;
} else {
if ($day->visibilityDistanceUnit === 'km') {
// Convert meters to KM
$day->visibility = $day->visibility / 1000;
}
}
}
// not available
$day->dewPoint = $item['dew_point'] ?? '--';
$day->uvIndex = $item['uvi'] ?? '--';
$day->ozone = '--';
// Map icon
$icons = self::iconMap();
$icon = $item['weather'][0]['icon'];
$day->icon = $icons['backgrounds'][$icon] ?? 'wi-na';
$day->wicon = $icons['weather-icons'][$icon] ?? 'wi-na';
}
/**
* @inheritDoc
*/
public static function supportedLanguages()
{
return [
['id' => 'af', 'value' => __('Afrikaans')],
['id' => 'ar', 'value' => __('Arabic')],
['id' => 'az', 'value' => __('Azerbaijani')],
['id' => 'bg', 'value' => __('Bulgarian')],
['id' => 'ca', 'value' => __('Catalan')],
['id' => 'zh_cn', 'value' => __('Chinese Simplified')],
['id' => 'zh_tw', 'value' => __('Chinese Traditional')],
['id' => 'cz', 'value' => __('Czech')],
['id' => 'da', 'value' => __('Danish')],
['id' => 'de', 'value' => __('German')],
['id' => 'el', 'value' => __('Greek')],
['id' => 'en', 'value' => __('English')],
['id' => 'eu', 'value' => __('Basque')],
['id' => 'fa', 'value' => __('Persian (Farsi)')],
['id' => 'fi', 'value' => __('Finnish')],
['id' => 'fr', 'value' => __('French')],
['id' => 'gl', 'value' => __('Galician')],
['id' => 'he', 'value' => __('Hebrew')],
['id' => 'hi', 'value' => __('Hindi')],
['id' => 'hr', 'value' => __('Croatian')],
['id' => 'hu', 'value' => __('Hungarian')],
['id' => 'id', 'value' => __('Indonesian')],
['id' => 'it', 'value' => __('Italian')],
['id' => 'ja', 'value' => __('Japanese')],
['id' => 'kr', 'value' => __('Korean')],
['id' => 'la', 'value' => __('Latvian')],
['id' => 'lt', 'value' => __('Lithuanian')],
['id' => 'mk', 'value' => __('Macedonian')],
['id' => 'no', 'value' => __('Norwegian')],
['id' => 'nl', 'value' => __('Dutch')],
['id' => 'pl', 'value' => __('Polish')],
['id' => 'pt', 'value' => __('Portuguese')],
['id' => 'pt_br', 'value' => __('Português Brasil')],
['id' => 'ro', 'value' => __('Romanian')],
['id' => 'ru', 'value' => __('Russian')],
['id' => 'se', 'value' => __('Swedish')],
['id' => 'sk', 'value' => __('Slovak')],
['id' => 'sl', 'value' => __('Slovenian')],
['id' => 'es', 'value' => __('Spanish')],
['id' => 'sr', 'value' => __('Serbian')],
['id' => 'th', 'value' => __('Thai')],
['id' => 'tr', 'value' => __('Turkish')],
['id' => 'uk', 'value' => __('Ukrainian')],
['id' => 'vi', 'value' => __('Vietnamese')],
['id' => 'zu', 'value' => __('Zulu')]
];
}
/**
* @return array
*/
private function iconMap()
{
return [
'weather-icons' => [
'01d' => 'wi-day-sunny',
'01n' => 'wi-night-clear',
'02d' => 'wi-day-cloudy',
'02n' => 'wi-night-partly-cloudy',
'03d' => 'wi-cloudy',
'03n' => 'wi-night-cloudy',
'04d' => 'wi-day-cloudy',
'04n' => 'wi-night-partly-cloudy',
'09d' => 'wi-rain',
'09n' => 'wi-night-rain',
'10d' => 'wi-rain',
'10n' => 'wi-night-rain',
'11d' => 'wi-day-thunderstorm',
'11n' => 'wi-night-thunderstorm',
'13d' => 'wi-day-snow',
'13n' => 'wi-night-snow',
'50d' => 'wi-day-fog',
'50n' => 'wi-night-fog'
],
'backgrounds' => [
'01d' => 'clear-day',
'01n' => 'clear-night',
'02d' => 'partly-cloudy-day',
'02n' => 'partly-cloudy-night',
'03d' => 'cloudy',
'03n' => 'cloudy',
'04d' => 'partly-cloudy-day',
'04n' => 'partly-cloudy-night',
'09d' => 'rain',
'09n' => 'rain',
'10d' => 'rain',
'10n' => 'rain',
'11d' => 'wind',
'11n' => 'wind',
'13d' => 'snow',
'13n' => 'snow',
'50d' => 'fog',
'50n' => 'fog'
]
];
}
/** @inheritDoc */
public static function unitsAvailable()
{
return [
['id' => 'auto', 'value' => 'Automatically select based on geographic location', 'tempUnit' => '', 'windUnit' => '', 'visibilityUnit' => ''],
['id' => 'ca', 'value' => 'Canada', 'tempUnit' => 'C', 'windUnit' => 'KPH', 'visibilityUnit' => 'km'],
['id' => 'si', 'value' => 'Standard International Units', 'tempUnit' => 'C', 'windUnit' => 'MPS', 'visibilityUnit' => 'km'],
['id' => 'uk2', 'value' => 'United Kingdom', 'tempUnit' => 'C', 'windUnit' => 'MPH', 'visibilityUnit' => 'mi'],
['id' => 'us', 'value' => 'United States', 'tempUnit' => 'F', 'windUnit' => 'MPH', 'visibilityUnit' => 'mi'],
];
}
/**
* @param $code
* @return mixed|null
*/
public function getUnit($code)
{
foreach (self::unitsAvailable() as $unit) {
if ($unit['id'] == $code) {
return $unit;
}
}
return null;
}
/**
* @return array
*/
private static function cardinalDirections()
{
return [
'N' => [337.5, 22.5],
'NE' => [22.5, 67.5],
'E' => [67.5, 112.5],
'SE' => [112.5, 157.5],
'S' => [157.5, 202.5],
'SW' => [202.5, 247.5],
'W' => [247.5, 292.5],
'NW' => [292.5, 337.5]
];
}
/**
* @param ScheduleCriteriaRequestInterface $event
* @return void
* @throws ConfigurationException
*/
public function onScheduleCriteriaRequest(ScheduleCriteriaRequestInterface $event): void
{
// Initialize Open Weather Schedule Criteria parameters
$event->addType('weather', __('Weather'))
->addMetric('weather_condition', __('Weather Condition'))
->addCondition([
'eq' => __('Equal to')
])
->addValues('dropdown', [
'thunderstorm' => __('Thunderstorm'),
'drizzle' => __('Drizzle'),
'rain' => __('Rain'),
'snow' => __('Snow'),
'clear' => __('Clear'),
'clouds' => __('Clouds')
])
->addMetric('weather_temp_imperial', __('Temperature (Imperial)'))
->addCondition([
'lt' => __('Less than'),
'lte' => __('Less than or equal to'),
'eq' => __('Equal to'),
'gte' => __('Greater than or equal to'),
'gt' => __('Greater than')
])
->addValues('number', [])
->addMetric('weather_temp_metric', __('Temperature (Metric)'))
->addCondition([
'lt' => __('Less than'),
'lte' => __('Less than or equal to'),
'eq' => __('Equal to'),
'gte' => __('Greater than or equal to'),
'gt' => __('Greater than')
])
->addValues('number', [])
->addMetric('weather_feels_like_imperial', __('Apparent Temperature (Imperial)'))
->addCondition([
'lt' => __('Less than'),
'lte' => __('Less than or equal to'),
'eq' => __('Equal to'),
'gte' => __('Greater than or equal to'),
'gt' => __('Greater than')
])
->addValues('number', [])
->addMetric('weather_feels_like_metric', __('Apparent Temperature (Metric)'))
->addCondition([
'lt' => __('Less than'),
'lte' => __('Less than or equal to'),
'eq' => __('Equal to'),
'gte' => __('Greater than or equal to'),
'gt' => __('Greater than')
])
->addValues('number', [])
->addMetric('weather_wind_speed', __('Wind Speed'))
->addCondition([
'lt' => __('Less than'),
'lte' => __('Less than or equal to'),
'eq' => __('Equal to'),
'gte' => __('Greater than or equal to'),
'gt' => __('Greater than')
])
->addValues('number', [])
->addMetric('weather_wind_direction', __('Wind Direction'))
->addCondition([
'eq' => __('Equal to')
])
->addValues('dropdown', [
'N' => __('North'),
'NE' => __('Northeast'),
'E' => __('East'),
'SE' => __('Southeast'),
'S' => __('South'),
'SW' => __('Southwest'),
'W' => __('West'),
'NW' => __('Northwest'),
])
->addMetric('weather_wind_degrees', __('Wind Direction (degrees)'))
->addCondition([
'lt' => __('Less than'),
'lte' => __('Less than or equal to'),
'eq' => __('Equal to'),
'gte' => __('Greater than or equal to'),
'gt' => __('Greater than')
])
->addValues('number', [])
->addMetric('weather_humidity', __('Humidity (Percent)'))
->addCondition([
'lt' => __('Less than'),
'lte' => __('Less than or equal to'),
'eq' => __('Equal to'),
'gte' => __('Greater than or equal to'),
'gt' => __('Greater than')
])
->addValues('number', [])
->addMetric('weather_pressure', __('Pressure'))
->addCondition([
'lt' => __('Less than'),
'lte' => __('Less than or equal to'),
'eq' => __('Equal to'),
'gte' => __('Greater than or equal to'),
'gt' => __('Greater than')
])
->addValues('number', [])
->addMetric('weather_visibility', __('Visibility (meters)'))
->addCondition([
'lt' => __('Less than'),
'lte' => __('Less than or equal to'),
'eq' => __('Equal to'),
'gte' => __('Greater than or equal to'),
'gt' => __('Greater than')
])
->addValues('number', []);
}
/**
* @param $item
* @param $unit
* @param $requestUnit
* @return array
*/
private function processXmdsWeatherData($item, $unit, $requestUnit): array
{
$windSpeedUnit = $unit['windUnit'] ?? 'KPH';
$visibilityDistanceUnit = $unit['visibilityUnit'] ?? 'km';
// var to store output/response
$data = array();
// format the weather condition
$data['weather_condition'] = str_replace(' ', '_', strtolower($item['weather'][0]['main']));
// Temperature
// imperial = F
// metric = C
$tempImperial = $item['temp'];
$apparentTempImperial = $item['feels_like'];
// Convert F to C
$tempMetric = ($tempImperial - 32) * 5 / 9;
$apparentTempMetric = ($apparentTempImperial - 32) * 5 / 9;
// Round those temperature values
$data['weather_temp_imperial'] = round($tempImperial, 0);
$data['weather_feels_like_imperial'] = round($apparentTempImperial, 0);
$data['weather_temp_metric'] = round($tempMetric, 0);
$data['weather_feels_like_metric'] = round($apparentTempMetric, 0);
// Humidity
$data['weather_humidity'] = $item['humidity'];
// Pressure
// received in hPa, display in mB
$data['weather_pressure'] = $item['pressure'] / 100;
// Wind
// metric = meters per second
// imperial = miles per hour
$data['weather_wind_speed'] = $item['wind_speed'] ?? $item['speed'] ?? null;
$data['weather_wind_degrees'] = $item['wind_deg'] ?? $item['deg'] ?? null;
if ($requestUnit === 'metric' && $windSpeedUnit !== 'MPS') {
// We have MPS and need to go to something else
if ($windSpeedUnit === 'MPH') {
// Convert MPS to MPH
$data['weather_wind_degrees'] = round($data['weather_wind_degrees'] * 2.237, 2);
} else if ($windSpeedUnit === 'KPH') {
// Convert MPS to KPH
$data['weather_wind_degrees'] = round($data['weather_wind_degrees'] * 3.6, 2);
}
} else if ($requestUnit === 'imperial' && $windSpeedUnit !== 'MPH') {
if ($windSpeedUnit === 'MPS') {
// Convert MPH to MPS
$data['weather_wind_degrees'] = round($data['weather_wind_degrees'] / 2.237, 2);
} else if ($windSpeedUnit === 'KPH') {
// Convert MPH to KPH
$data['weather_wind_degrees'] = round($data['weather_wind_degrees'] * 1.609344, 2);
}
}
// Wind direction
$data['weather_wind_direction'] = '--';
if ($data['weather_wind_degrees'] !== null && $data['weather_wind_degrees'] !== 0) {
foreach (self::cardinalDirections() as $dir => $angles) {
if ($data['weather_wind_degrees'] >= $angles[0] && $data['weather_wind_degrees'] < $angles[1]) {
$data['weather_wind_direction'] = $dir;
break;
}
}
}
// Visibility
// metric = meters
// imperial = meters?
$data['weather_visibility'] = $item['visibility'] ?? '--';
if ($data['weather_visibility'] !== '--') {
// Always in meters
if ($visibilityDistanceUnit === 'mi') {
// Convert meters to miles
$data['weather_visibility'] = $data['weather_visibility'] / 1609;
} else {
if ($visibilityDistanceUnit === 'km') {
// Convert meters to KM
$data['weather_visibility'] = $data['weather_visibility'] / 1000;
}
}
}
return $data;
}
/**
* @param XmdsWeatherRequestEvent $event
* @return void
* @throws GeneralException|\SoapFault
*/
public function onXmdsWeatherRequest(XmdsWeatherRequestEvent $event): void
{
// check for API Key
if (empty($this->getSetting('owmApiKey'))) {
$this->getLogger()->debug('onXmdsWeatherRequest: Open Weather Map not configured.');
throw new \SoapFault(
'Receiver',
'Open Weather Map API key is not configured'
);
}
$latitude = $event->getLatitude();
$longitude = $event->getLongitude();
// Cache expiry date
$cacheExpire = Carbon::now()->addHours($this->getSetting('xmdsCachePeriod'));
// use imperial as the default units, so we can get the right value when converting to metric
$units = 'imperial';
// Temperature and Wind Speed Unit Mappings
$unit = $this->getUnit('auto');
// Build the URL
$url = '?lat=' . $latitude
. '&lon=' . $longitude
. '&units=' . $units
. '&appid=[API_KEY]';
// check API plan
if ($this->getSetting('owmIsPaidPlan') ?? 0 == 1) {
// use weather data endpoints for Paid Plan
$data = $this->queryApi($this->apiUrl . $this->forecastCurrent . $url, $cacheExpire);
$data['current'] = $this->parseCurrentIntoFormat($data);
// Pick out the country
$country = $data['sys']['country'] ?? null;
// If we don't have a unit, then can we base it on the timezone we got back?
if ($country !== null) {
// Pick out some countries to set the units
if ($country === 'GB') {
$unit = $this->getUnit('uk2');
} else if ($country === 'US') {
$unit = $this->getUnit('us');
} else if ($country === 'CA') {
$unit = $this->getUnit('ca');
} else {
$unit = $this->getUnit('si');
}
}
} else {
// We use one call API 3.0 for Free Plan
$data = $this->queryApi($this->apiUrl . $this->forecastCombinedV3 . $url, $cacheExpire);
// Country based on timezone (this is harder than using the real country)
if (Str::startsWith($data['timezone'], 'America')) {
$unit = $this->getUnit('us');
} else if ($data['timezone'] === 'Europe/London') {
$unit = $this->getUnit('uk2');
} else {
$unit = $this->getUnit('si');
}
}
// process weather data
$weatherData = $this->processXmdsWeatherData($data['current'], $unit, 'imperial');
// Set the processed weather data in the event as a JSON-encoded string
$event->setWeatherData(json_encode($weatherData));
}
}

View File

@@ -0,0 +1,324 @@
<?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 Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Xibo\Entity\SearchResult;
use Xibo\Event\LibraryProviderEvent;
use Xibo\Event\LibraryProviderImportEvent;
use Xibo\Event\LibraryProviderListEvent;
use Xibo\Support\Sanitizer\SanitizerInterface;
/**
* Pixabay Connector
* This connector acts as a data provider for the Media Toolbar in the Layout/Playlist editor user interface
*/
class PixabayConnector implements ConnectorInterface
{
use ConnectorTrait;
public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
{
$dispatcher->addListener('connector.provider.library', [$this, 'onLibraryProvider']);
$dispatcher->addListener('connector.provider.library.import', [$this, 'onLibraryImport']);
$dispatcher->addListener('connector.provider.library.list', [$this, 'onLibraryList']);
return $this;
}
public function getSourceName(): string
{
return 'pixabay';
}
public function getTitle(): string
{
return 'Pixabay';
}
public function getDescription(): string
{
return 'Show Pixabay images and videos in the Layout editor toolbar and download them to the library for use on your Layouts.';
}
public function getThumbnail(): string
{
return 'theme/default/img/connectors/pixabay_square_green.png';
}
public function getFilters(): array
{
return [
[
'name' => 'name',
'type' => 'string',
'key' => 'media'
],
[
'label' => 'type',
'type' => 'dropdown',
'options' => [
[
'name' => 'Image',
'value' => 'image'
],
[
'name' => 'Video',
'value' => 'video'
]
]
],
[
'label' => 'orientation',
'type' => 'dropdown',
'options' => [
[
'name' => 'All',
'value' => ''
],
[
'name' => 'Landscape',
'value' => 'landscape'
],
[
'name' => 'Portrait',
'value' => 'portrait'
]
],
'visibility' => [
'field' => 'type',
'type' => 'eq',
'value' => 'image'
]
]
];
}
public function getSettingsFormTwig(): string
{
return 'pixabay-form-settings';
}
public function processSettingsForm(SanitizerInterface $params, array $settings): array
{
if (!$this->isProviderSetting('apiKey')) {
$settings['apiKey'] = $params->getString('apiKey');
}
return $settings;
}
/**
* @param \Xibo\Event\LibraryProviderEvent $event
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function onLibraryProvider(LibraryProviderEvent $event)
{
$this->getLogger()->debug('onLibraryProvider');
// Do we have an alternative URL (we may proxy requests for cache)
$baseUrl = $this->getSetting('baseUrl');
if (empty($baseUrl)) {
$baseUrl = 'https://pixabay.com/api/';
}
// Do we have an API key?
$apiKey = $this->getSetting('apiKey');
if (empty($apiKey)) {
$this->getLogger()->debug('onLibraryProvider: No api key');
return;
}
// was Pixabay requested?
if ($event->getProviderName() === $this->getSourceName()) {
// We do! Let's get some results from Pixabay
// first we look at paging
$start = $event->getStart();
$perPage = $event->getLength();
if ($start == 0) {
$page = 1;
} else {
$page = floor($start / $perPage) + 1;
}
$query = [
'key' => $apiKey,
'page' => $page,
'per_page' => $perPage,
'safesearch' => 'true'
];
// Now we handle any other search
if ($event->getOrientation() === 'landscape') {
$query['orientation'] = 'horizontal';
} else if ($event->getOrientation() === 'portrait') {
$query['orientation'] = 'vertical';
}
if (!empty($event->getSearch())) {
$query['q'] = urlencode($event->getSearch());
}
// Pixabay either returns images or videos, not both.
if (count($event->getTypes()) !== 1) {
return;
}
$type = $event->getTypes()[0];
if (!in_array($type, ['image', 'video'])) {
return;
}
// Pixabay require a 24-hour cache of each result set.
$key = md5($type . '_' . json_encode($query));
$cache = $this->getPool()->getItem($key);
$body = $cache->get();
if ($cache->isMiss()) {
$this->getLogger()->debug('onLibraryProvider: cache miss, generating.');
// Make the request
$request = $this->getClient()->request('GET', $baseUrl . ($type === 'video' ? 'videos' : ''), [
'query' => $query
]);
$body = $request->getBody()->getContents();
if (empty($body)) {
$this->getLogger()->debug('onLibraryProvider: Empty body');
return;
}
$body = json_decode($body);
if ($body === null || $body === false) {
$this->getLogger()->debug('onLibraryProvider: non-json body or empty body returned.');
return;
}
// Cache for next time
$cache->set($body);
$cache->expiresAt(Carbon::now()->addHours(24));
$this->getPool()->saveDeferred($cache);
} else {
$this->getLogger()->debug('onLibraryProvider: serving from cache.');
}
$providerDetails = new ProviderDetails();
$providerDetails->id = 'pixabay';
$providerDetails->link = 'https://pixabay.com';
$providerDetails->logoUrl = '/theme/default/img/connectors/pixabay_logo.svg';
$providerDetails->iconUrl = '/theme/default/img/connectors/pixabay_logo_square.svg';
$providerDetails->backgroundColor = '';
$providerDetails->filters = $this->getFilters();
// Process each hit into a search result and add it to the overall results we've been given.
foreach ($body->hits as $result) {
$searchResult = new SearchResult();
$searchResult->source = $this->getSourceName();
$searchResult->id = $result->id;
$searchResult->title = $result->tags;
$searchResult->provider = $providerDetails;
if ($type === 'video') {
$searchResult->type = 'video';
$searchResult->thumbnail = $result->videos->tiny->url;
$searchResult->duration = $result->duration;
// As per Pixabay, medium videos are usually 1080p but in some cases,
// it might be larger (ie 2560x1440) so we need to do an additional validation
if (!empty($result->videos->medium) && $result->videos->medium->width <= 1920
&& $result->videos->medium->height <= 1920
) {
$searchResult->download = $result->videos->medium->url;
$searchResult->width = $result->videos->medium->width;
$searchResult->height = $result->videos->medium->height;
$searchResult->fileSize = $result->videos->medium->size;
} else if (!empty($result->videos->small)) {
$searchResult->download = $result->videos->small->url;
$searchResult->width = $result->videos->small->width;
$searchResult->height = $result->videos->small->height;
$searchResult->fileSize = $result->videos->small->size;
} else {
$searchResult->download = $result->videos->tiny->url;
$searchResult->width = $result->videos->tiny->width;
$searchResult->height = $result->videos->tiny->height;
$searchResult->fileSize = $result->videos->tiny->size;
}
if (!empty($result->picture_id ?? null)) {
// Try the old way (at some point this stopped working and went to the thumbnail approach above
$searchResult->videoThumbnailUrl = str_replace(
'pictureId',
$result->picture_id,
'https://i.vimeocdn.com/video/pictureId_960x540.png'
);
} else {
// Use the medium thumbnail if we have it, otherwise the tiny one.
$searchResult->videoThumbnailUrl = $result->videos->medium->thumbnail
?? $result->videos->tiny->thumbnail;
}
} else {
$searchResult->type = 'image';
$searchResult->thumbnail = $result->previewURL;
$searchResult->download = $result->fullHDURL ?? $result->largeImageURL;
$searchResult->width = $result->imageWidth;
$searchResult->height = $result->imageHeight;
$searchResult->fileSize = $result->imageSize;
}
$event->addResult($searchResult);
}
}
}
/**
* @param \Xibo\Event\LibraryProviderImportEvent $event
*/
public function onLibraryImport(LibraryProviderImportEvent $event)
{
foreach ($event->getItems() as $providerImport) {
if ($providerImport->searchResult->provider->id === $this->getSourceName()) {
// Configure this import, setting the URL, etc.
$providerImport->configureDownload();
}
}
}
public function onLibraryList(LibraryProviderListEvent $event)
{
$this->getLogger()->debug('onLibraryList:event');
if (empty($this->getSetting('apiKey'))) {
$this->getLogger()->debug('onLibraryList: No api key');
return;
}
$providerDetails = new ProviderDetails();
$providerDetails->id = 'pixabay';
$providerDetails->link = 'https://pixabay.com';
$providerDetails->logoUrl = '/theme/default/img/connectors/pixabay_logo.svg';
$providerDetails->iconUrl = '/theme/default/img/connectors/pixabay_logo_square.svg';
$providerDetails->backgroundColor = '';
$providerDetails->mediaTypes = ['image', 'video'];
$providerDetails->filters = $this->getFilters();
$event->addProvider($providerDetails);
}
}

View File

@@ -0,0 +1,52 @@
<?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;
/**
* Provider Details
*/
class ProviderDetails implements \JsonSerializable
{
public $id;
public $message;
public $link;
public $logoUrl;
public $iconUrl;
public $backgroundColor;
public $mediaTypes;
public $filters;
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'message' => $this->message,
'link' => $this->link,
'logoUrl' => $this->logoUrl,
'iconUrl' => $this->iconUrl,
'backgroundColor' => $this->backgroundColor,
'mediaTypes' => $this->mediaTypes,
'filters' => $this->filters
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
/*
* Copyright (C) 2023 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;
/**
* A provider import request/result.
* This is used to exchange a search result from a provider for a mediaId in the library.
*/
class ProviderImport implements \JsonSerializable
{
/** @var \Xibo\Entity\SearchResult */
public $searchResult;
/** @var \Xibo\Entity\Media media */
public $media;
/** @var bool has this been configured for import */
public $isConfigured = false;
/** @var string the URL to use for the download */
public $url;
/** @var bool has this been uploaded */
public $isUploaded = false;
/** @var bool is error state? */
public $isError = false;
/** @var string error message, if in error state */
public $error;
/**
* @return \Xibo\Connector\ProviderImport
*/
public function configureDownload(): ProviderImport
{
$this->isConfigured = true;
$this->url = $this->searchResult->download;
return $this;
}
/**
* @param $message
* @return $this
*/
public function setError($message): ProviderImport
{
$this->isUploaded = false;
$this->isError = true;
$this->error = $message;
return $this;
}
/**
* @return array
*/
public function jsonSerialize(): array
{
return [
'item' => $this->searchResult,
'media' => $this->media,
'isUploaded' => $this->isUploaded,
'isError' => $this->isError,
'error' => $this->error
];
}
}

View File

@@ -0,0 +1,977 @@
<?php
/*
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* 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 GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\ServerException;
use Psr\Container\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Xibo\Entity\User;
use Xibo\Event\ConnectorReportEvent;
use Xibo\Event\MaintenanceRegularEvent;
use Xibo\Event\ReportDataEvent;
use Xibo\Factory\CampaignFactory;
use Xibo\Factory\DisplayFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Helper\SanitizerService;
use Xibo\Service\ConfigServiceInterface;
use Xibo\Storage\TimeSeriesStoreInterface;
use Xibo\Support\Exception\AccessDeniedException;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Support\Sanitizer\SanitizerInterface;
class XiboAudienceReportingConnector implements ConnectorInterface
{
use ConnectorTrait;
/** @var User */
private $user;
/** @var TimeSeriesStoreInterface */
private $timeSeriesStore;
/** @var SanitizerService */
private $sanitizer;
/** @var ConfigServiceInterface */
private $config;
/** @var CampaignFactory */
private $campaignFactory;
/** @var DisplayFactory */
private $displayFactory;
/**
* @param \Psr\Container\ContainerInterface $container
* @return \Xibo\Connector\ConnectorInterface
*/
public function setFactories(ContainerInterface $container): ConnectorInterface
{
$this->user = $container->get('user');
$this->timeSeriesStore = $container->get('timeSeriesStore');
$this->sanitizer = $container->get('sanitizerService');
$this->config = $container->get('configService');
$this->campaignFactory = $container->get('campaignFactory');
$this->displayFactory = $container->get('displayFactory');
return $this;
}
public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
{
$dispatcher->addListener(MaintenanceRegularEvent::$NAME, [$this, 'onRegularMaintenance']);
$dispatcher->addListener(ReportDataEvent::$NAME, [$this, 'onRequestReportData']);
$dispatcher->addListener(ConnectorReportEvent::$NAME, [$this, 'onListReports']);
return $this;
}
public function getSourceName(): string
{
return 'xibo-audience-reporting-connector';
}
public function getTitle(): string
{
return 'Xibo Audience Reporting Connector';
}
/**
* Get the service url, either from settings or a default
* @return string
*/
private function getServiceUrl(): string
{
return $this->getSetting('serviceUrl', 'https://exchange.xibo-adspace.com/api');
}
public function getDescription(): string
{
return 'Enhance your reporting with audience data, impressions and more.';
}
public function getThumbnail(): string
{
return 'theme/default/img/connectors/xibo-audience-reporting.png';
}
public function getSettingsFormTwig(): string
{
return 'xibo-audience-connector-form-settings';
}
public function getSettingsFormJavaScript(): string
{
return 'xibo-audience-connector-form-javascript';
}
public function processSettingsForm(SanitizerInterface $params, array $settings): array
{
if (!$this->isProviderSetting('apiKey')) {
$settings['apiKey'] = $params->getString('apiKey');
}
// Get this connector settings, etc.
$this->getOptionsFromAxe($settings['apiKey'], true);
return $settings;
}
// <editor-fold desc="Listeners">
/**
* @throws NotFoundException
* @throws GeneralException
*/
public function onRegularMaintenance(MaintenanceRegularEvent $event)
{
// We should only do this if the connector is enabled and if we have an API key
$apiKey = $this->getSetting('apiKey');
if (empty($apiKey)) {
$this->getLogger()->debug('onRegularMaintenance: No api key');
return;
}
$event->addMessage('## Audience Connector');
// Set displays on DMAs
foreach ($this->dmaSearch($this->sanitizer->getSanitizer([]))['data'] as $dma) {
if ($dma['displayGroupId'] !== null) {
$this->setDisplaysForDma($dma['_id'], $dma['displayGroupId']);
}
}
// Handle sending stats to the audience connector service API
try {
$defaultTimezone = $this->config->getSetting('defaultTimezone');
// Get Watermark (might be null - start from beginning)
$watermark = $this->getWatermark();
// Loop over 5000 stat records
// Only interested in layout stats which belong to a parent campaign
$params = [
'type' => 'layout',
'start' => 0,
'length' => $this->getSetting('batchSize', 5000),
'mustHaveParentCampaign' => true,
];
// If the watermark is not empty, we go from this point
if (!empty($watermark)) {
$params['statId'] = $watermark;
}
$this->getLogger()->debug('onRegularMaintenance: Processing batch of stats with params: '
. json_encode($params));
// Call the time series interface getStats
$resultSet = $this->timeSeriesStore->getStats($params, true);
// Array of campaigns for which we will update the total spend, impresssions, and plays
$campaigns = [];
$adCampaignCache = [];
$listCampaignCache = [];
$displayCache = [];
$displayIdsDeleted = [];
$erroredCampaign = [];
$rows = [];
$updateWatermark = null;
// Process the stats one by one
while ($row = $resultSet->getNextRow()) {
try {
$sanitizedRow = $this->sanitizer->getSanitizer($row);
$parentCampaignId = $sanitizedRow->getInt('parentCampaignId', ['default' => 0]);
$displayId = $sanitizedRow->getInt('displayId');
$statId = $resultSet->getIdFromRow($row);
// Keep this watermark, so we update it later
$updateWatermark = $statId;
// Skip records we're not interested in, or records that have already been discounted before.
if (empty($parentCampaignId)
|| empty($displayId)
|| in_array($displayId, $displayIdsDeleted)
|| array_key_exists($parentCampaignId, $erroredCampaign)
|| array_key_exists($parentCampaignId, $listCampaignCache)
) {
// Comment out this log to save recording messages unless we need to troubleshoot in dev
//$this->getLogger()->debug('onRegularMaintenance: Campaign is a list campaign '
// . $parentCampaignId);
continue;
}
// Build an array to represent the row we want to send.
$entry = [
'id' => $statId,
'parentCampaignId' => $parentCampaignId,
'displayId' => $displayId,
];
// --------
// Get Campaign
// Campaign start and end date
if (array_key_exists($parentCampaignId, $adCampaignCache)) {
$entry['campaignStart'] = $adCampaignCache[$parentCampaignId]['start'];
$entry['campaignEnd'] = $adCampaignCache[$parentCampaignId]['end'];
} else {
// Get Campaign
try {
$parentCampaign = $this->campaignFactory->getById($parentCampaignId);
} catch (\Exception $exception) {
$this->getLogger()->error('onRegularMaintenance: campaign with ID '
. $parentCampaignId . ' not found');
$erroredCampaign[$parentCampaignId] = $entry['id']; // first stat id
continue;
}
if ($parentCampaign->type == 'ad') {
$adCampaignCache[$parentCampaignId]['type'] = $parentCampaign->type;
} else {
$this->getLogger()->debug('onRegularMaintenance: campaign is a list '
. $parentCampaignId);
$listCampaignCache[$parentCampaignId] = $parentCampaignId;
continue;
}
if (!empty($parentCampaign->getStartDt()) && !empty($parentCampaign->getEndDt())) {
$adCampaignCache[$parentCampaignId]['start'] = $parentCampaign->getStartDt()
->format(DateFormatHelper::getSystemFormat());
$adCampaignCache[$parentCampaignId]['end'] = $parentCampaign->getEndDt()
->format(DateFormatHelper::getSystemFormat());
$entry['campaignStart'] = $adCampaignCache[$parentCampaignId]['start'];
$entry['campaignEnd'] = $adCampaignCache[$parentCampaignId]['end'];
} else {
$this->getLogger()->error('onRegularMaintenance: campaign without dates '
. $parentCampaignId);
$erroredCampaign[$parentCampaignId] = $entry['id']; // first stat id
continue;
}
}
// Get Display
// -----------
// Cost per play and impressions per play
if (!array_key_exists($displayId, $displayCache)) {
try {
$display = $this->displayFactory->getById($displayId);
$displayCache[$displayId]['costPerPlay'] = $display->costPerPlay;
$displayCache[$displayId]['impressionsPerPlay'] = $display->impressionsPerPlay;
$displayCache[$displayId]['timeZone'] = empty($display->timeZone) ? $defaultTimezone : $display->timeZone;
} catch (NotFoundException $notFoundException) {
$this->getLogger()->error('onRegularMaintenance: display not found with ID: '
. $displayId);
$displayIdsDeleted[] = $displayId;
continue;
}
}
$entry['costPerPlay'] = $displayCache[$displayId]['costPerPlay'];
$entry['impressionsPerPlay'] = $displayCache[$displayId]['impressionsPerPlay'];
// Converting the date into the format expected by the API
// --------
// We know that player's local dates were stored in the CMS's configured timezone
// Dates were saved in Unix timestamps in MySQL
// Dates were saved in UTC format in MongoDB
// The main difference is that MySQL stores dates in the timezone of the CMS,
// while MongoDB converts those dates to UTC before storing them.
// -----MySQL
// Carbon::createFromTimestamp() always applies the CMS timezone
// ------MongoDB
// $date->toDateTime() returns a PHP DateTime object from MongoDB BSON Date type (UTC)
// Carbon::instance() keeps the timezone as UTC
try {
$start = $resultSet->getDateFromValue($row['start']);
$end = $resultSet->getDateFromValue($row['end']);
} catch (\Exception $exception) {
$this->getLogger()->error('onRegularMaintenance: Date convert failed for ID '
. $entry['id'] . ' with error: '. $exception->getMessage());
continue;
}
// Convert dates to display timezone
$entry['start'] = $start->timezone($displayCache[$displayId]['timeZone'])->format(DateFormatHelper::getSystemFormat());
$entry['end'] = $end->timezone($displayCache[$displayId]['timeZone'])->format(DateFormatHelper::getSystemFormat());
$entry['layoutId'] = $sanitizedRow->getInt('layoutId', ['default' => 0]);
$entry['numberPlays'] = $sanitizedRow->getInt('count', ['default' => 0]);
$entry['duration'] = $sanitizedRow->getInt('duration', ['default' => 0]);
$entry['engagements'] = $resultSet->getEngagementsFromRow($row);
$rows[] = $entry;
// Campaign list in array
if (!in_array($parentCampaignId, $campaigns)) {
$campaigns[] = $parentCampaignId;
}
} catch (\Exception $exception) {
$this->getLogger()->error('onRegularMaintenance: unexpected exception processing row '
. ($entry['id'] ?? null) . ', e: ' . $exception->getMessage());
}
}
if (count($erroredCampaign) > 0) {
$event->addMessage(sprintf(
__('There were %d campaigns which failed. A summary is in the error log.'),
count($erroredCampaign)
));
$this->getLogger()->error('onRegularMaintenance: Failure summary of campaignId and first statId:'
. json_encode($erroredCampaign));
}
$this->getLogger()->debug('onRegularMaintenance: Records to send: ' . count($rows)
. ', Watermark: ' . $watermark);
$this->getLogger()->debug('onRegularMaintenance: Campaigns: ' . json_encode($campaigns));
// If we have rows, send them.
if (count($rows) > 0) {
// All outcomes from here are either a break; or an exception to stop the loop.
try {
$response = $this->getClient()->post($this->getServiceUrl() . '/audience/receiveStats', [
'timeout' => $this->getSetting('receiveStatsTimeout', 300), // 5 minutes
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'json' => $rows
]);
$statusCode = $response->getStatusCode();
$this->getLogger()->debug('onRegularMaintenance: Receive Stats StatusCode: ' . $statusCode);
// Get Campaign Total
if ($statusCode == 204) {
$this->getAndUpdateCampaignTotal($campaigns);
}
$event->addMessage('Added ' . count($rows) . ' to audience API');
} catch (RequestException $requestException) {
// If a request fails completely, we should stop and log the error.
$this->getLogger()->error('onRegularMaintenance: Audience receiveStats: failed e = '
. $requestException->getMessage());
throw new GeneralException(__('Failed to send stats to audience API'));
}
}
// Update the last statId of the block as the watermark
if (!empty($updateWatermark)) {
$this->setWatermark($updateWatermark);
}
} catch (GeneralException $exception) {
// We should have recorded in the error log already, so we just append to the event message for task
// last run status.
$event->addMessage($exception->getMessage());
}
}
/**
* Get the watermark representing how far we've processed already
* @return mixed|null
* @throws \Xibo\Support\Exception\GeneralException
*/
private function getWatermark()
{
// If the watermark request fails, we should error.
try {
$this->getLogger()->debug('onRegularMaintenance: Get Watermark');
$response = $this->getClient()->get($this->getServiceUrl() . '/audience/watermark', [
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
]);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
return $json['watermark'] ?? null;
} catch (RequestException $requestException) {
$this->getLogger()->error('getWatermark: failed e = ' . $requestException->getMessage());
throw new GeneralException(__('Cannot get watermark'));
}
}
/**
* Set the watermark representing how far we've processed already
* @return void
* @throws \Xibo\Support\Exception\GeneralException
*/
private function setWatermark($watermark)
{
// If the watermark set fails, we should error.
try {
$this->getLogger()->debug('onRegularMaintenance: Set Watermark ' . $watermark);
$this->getClient()->post($this->getServiceUrl() . '/audience/watermark', [
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'json' => ['watermark' => $watermark]
]);
} catch (RequestException $requestException) {
$this->getLogger()->error('setWatermark: failed e = ' . $requestException->getMessage());
throw new GeneralException(__('Cannot set watermark'));
}
}
/**
* @param array $campaigns
* @return void
* @throws \Xibo\Support\Exception\GeneralException
*/
private function getAndUpdateCampaignTotal(array $campaigns)
{
$this->getLogger()->debug('onRegularMaintenance: Get Campaign Total');
try {
$response = $this->getClient()->get($this->getServiceUrl() . '/audience/campaignTotal', [
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'query' => [
'campaigns' => $campaigns
]
]);
$body = $response->getBody()->getContents();
$results = json_decode($body, true);
$this->getLogger()->debug('onRegularMaintenance: Campaign Total Results: ' . json_encode($results));
foreach ($results as $item) {
try {
// Save the total in the campaign
$campaign = $this->campaignFactory->getById($item['id']);
$this->getLogger()->debug('onRegularMaintenance: Campaign Id: ' . $item['id']
. ' Spend: ' . $campaign->spend . ' Impressions: ' . $campaign->impressions);
$campaign->spend = $item['spend'];
$campaign->impressions = $item['impressions'];
$campaign->overwritePlays();
$this->getLogger()->debug('onRegularMaintenance: Campaign Id: ' . $item['id']
. ' Spend(U): ' . $campaign->spend . ' Impressions(U): ' . $campaign->impressions);
} catch (NotFoundException $notFoundException) {
$this->getLogger()->error('onRegularMaintenance: campaignId '
. $item['id']. ' should have existed, but did not.');
throw new GeneralException(sprintf(__('Cannot update campaign status for %d'), $item['id']));
}
}
} catch (RequestException $requestException) {
$this->getLogger()->error('Campaign total: e = ' . $requestException->getMessage());
throw new GeneralException(__('Failed to update campaign totals.'));
}
}
/**
* Request Report results from the audience report service
*/
public function onRequestReportData(ReportDataEvent $event)
{
$this->getLogger()->debug('onRequestReportData');
$type = $event->getReportType();
$typeUrl = [
'campaignProofofplay' => $this->getServiceUrl() . '/audience/campaign/proofofplay',
'mobileProofofplay' => $this->getServiceUrl() . '/audience/campaign/proofofplay/mobile',
'displayAdPlay' => $this->getServiceUrl() . '/audience/display/adplays',
'displayPercentage' => $this->getServiceUrl() . '/audience/display/percentage'
];
if (array_key_exists($type, $typeUrl)) {
$json = [];
switch ($type) {
case 'campaignProofofplay':
// Get campaign proofofplay result
try {
$response = $this->getClient()->get($typeUrl[$type], [
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'query' => $event->getParams()
]);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
} catch (RequestException $requestException) {
$this->getLogger()->error('Get '. $type.': failed. e = ' . $requestException->getMessage());
$error = 'Failed to get campaign proofofplay result: '.$requestException->getMessage();
}
break;
case 'mobileProofofplay':
// Get mobile proofofplay result
try {
$response = $this->getClient()->get($typeUrl[$type], [
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'query' => $event->getParams()
]);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
} catch (RequestException $requestException) {
$this->getLogger()->error('Get '. $type.': failed. e = ' . $requestException->getMessage());
$error = 'Failed to get mobile proofofplay result: '.$requestException->getMessage();
}
break;
case 'displayAdPlay':
// Get display adplays result
try {
$response = $this->getClient()->get($typeUrl[$type], [
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'query' => $event->getParams()
]);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
} catch (RequestException $requestException) {
$this->getLogger()->error('Get '. $type.': failed. e = ' . $requestException->getMessage());
$error = 'Failed to get display adplays result: '.$requestException->getMessage();
}
break;
case 'displayPercentage':
// Get display played percentage result
try {
$response = $this->getClient()->get($typeUrl[$type], [
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'query' => $event->getParams()
]);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
} catch (RequestException $requestException) {
$this->getLogger()->error('Get '. $type.': failed. e = ' . $requestException->getMessage());
$error = 'Failed to get display played percentage result: '.$requestException->getMessage();
}
break;
default:
$this->getLogger()->error('Connector Report not found ');
}
$event->setResults([
'json' => $json,
'error' => $error ?? null
]);
}
}
/**
* Get this connector reports
* @param ConnectorReportEvent $event
* @return void
*/
public function onListReports(ConnectorReportEvent $event)
{
$this->getLogger()->debug('onListReports');
$connectorReports = [
[
'name'=> 'campaignProofOfPlay',
'description'=> 'Campaign Proof of Play',
'class'=> '\\Xibo\\Report\\CampaignProofOfPlay',
'type'=> 'Report',
'output_type'=> 'table',
'color'=> 'gray',
'fa_icon'=> 'fa-th',
'category'=> 'Connector Reports',
'feature'=> 'campaign-proof-of-play',
'adminOnly'=> 0,
'sort_order' => 1
],
[
'name'=> 'mobileProofOfPlay',
'description'=> 'Mobile Proof of Play',
'class'=> '\\Xibo\\Report\\MobileProofOfPlay',
'type'=> 'Report',
'output_type'=> 'table',
'color'=> 'green',
'fa_icon'=> 'fa-th',
'category'=> 'Connector Reports',
'feature'=> 'mobile-proof-of-play',
'adminOnly'=> 0,
'sort_order' => 2
],
[
'name'=> 'displayPercentage',
'description'=> 'Display Played Percentage',
'class'=> '\\Xibo\\Report\\DisplayPercentage',
'type'=> 'Chart',
'output_type'=> 'both',
'color'=> 'blue',
'fa_icon'=> 'fa-pie-chart',
'category'=> 'Connector Reports',
'feature'=> 'display-report',
'adminOnly'=> 0,
'sort_order' => 3
],
// [
// 'name'=> 'revenueByDisplayReport',
// 'description'=> 'Revenue by Display',
// 'class'=> '\\Xibo\\Report\\RevenueByDisplay',
// 'type'=> 'Report',
// 'output_type'=> 'table',
// 'color'=> 'green',
// 'fa_icon'=> 'fa-th',
// 'category'=> 'Connector Reports',
// 'feature'=> 'display-report',
// 'adminOnly'=> 0,
// 'sort_order' => 4
// ],
[
'name'=> 'displayAdPlay',
'description'=> 'Display Ad Plays',
'class'=> '\\Xibo\\Report\\DisplayAdPlay',
'type'=> 'Chart',
'output_type'=> 'both',
'color'=> 'red',
'fa_icon'=> 'fa-bar-chart',
'category'=> 'Connector Reports',
'feature'=> 'display-report',
'adminOnly'=> 0,
'sort_order' => 5
],
];
$reports = [];
foreach ($connectorReports as $connectorReport) {
// Compatibility check
if (!isset($connectorReport['feature']) || !isset($connectorReport['category'])) {
continue;
}
// Check if only allowed for admin
if ($this->user->userTypeId != 1) {
if (isset($connectorReport['adminOnly']) && !empty($connectorReport['adminOnly'])) {
continue;
}
}
$reports[$connectorReport['category']][] = (object) $connectorReport;
}
if (count($reports) > 0) {
$event->addReports($reports);
}
}
// </editor-fold>
// <editor-fold desc="Proxy methods">
public function dmaSearch(SanitizerInterface $params): array
{
try {
$response = $this->getClient()->get($this->getServiceUrl() . '/dma', [
'timeout' => 120,
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey'),
],
]);
$body = json_decode($response->getBody()->getContents(), true);
if (!$body) {
throw new GeneralException(__('No response'));
}
return [
'data' => $body,
'recordsTotal' => count($body),
];
} catch (\Exception $e) {
$this->getLogger()->error('activity: e = ' . $e->getMessage());
}
return [
'data' => [],
'recordsTotal' => 0,
];
}
/**
* @throws \Xibo\Support\Exception\InvalidArgumentException
* @throws \Xibo\Support\Exception\GeneralException
*/
public function dmaAdd(SanitizerInterface $params): array
{
$startDate = $params->getDate('startDate');
if ($startDate !== null) {
$startDate = $startDate->format('Y-m-d');
}
$endDate = $params->getDate('endDate');
if ($endDate !== null) {
$endDate = $endDate->format('Y-m-d');
}
try {
$response = $this->getClient()->post($this->getServiceUrl() . '/dma', [
'timeout' => 120,
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey'),
],
'json' => [
'name' => $params->getString('name'),
'costPerPlay' => $params->getDouble('costPerPlay'),
'impressionSource' => $params->getString('impressionSource'),
'impressionsPerPlay' => $params->getDouble('impressionsPerPlay'),
'startDate' => $startDate,
'endDate' => $endDate,
'daysOfWeek' => $params->getIntArray('daysOfWeek'),
'startTime' => $params->getString('startTime'),
'endTime' => $params->getString('endTime'),
'geoFence' => json_decode($params->getString('geoFence'), true),
'priority' => $params->getInt('priority'),
'displayGroupId' => $params->getInt('displayGroupId'),
],
]);
$body = json_decode($response->getBody()->getContents(), true);
if (!$body) {
throw new GeneralException(__('No response'));
}
// Set the displays
$this->setDisplaysForDma($body['_id'], $params->getInt('displayGroupId'));
return $body;
} catch (\Exception $e) {
$this->handleException($e);
}
}
/**
* @throws \Xibo\Support\Exception\InvalidArgumentException
* @throws \Xibo\Support\Exception\GeneralException
*/
public function dmaEdit(SanitizerInterface $params): array
{
$startDate = $params->getDate('startDate');
if ($startDate !== null) {
$startDate = $startDate->format('Y-m-d');
}
$endDate = $params->getDate('endDate');
if ($endDate !== null) {
$endDate = $endDate->format('Y-m-d');
}
try {
$response = $this->getClient()->put($this->getServiceUrl() . '/dma/' . $params->getString('_id'), [
'timeout' => 120,
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey'),
],
'json' => [
'name' => $params->getString('name'),
'costPerPlay' => $params->getDouble('costPerPlay'),
'impressionSource' => $params->getString('impressionSource'),
'impressionsPerPlay' => $params->getDouble('impressionsPerPlay'),
'startDate' => $startDate,
'endDate' => $endDate,
'daysOfWeek' => $params->getIntArray('daysOfWeek'),
'startTime' => $params->getString('startTime'),
'endTime' => $params->getString('endTime'),
'geoFence' => json_decode($params->getString('geoFence'), true),
'priority' => $params->getInt('priority'),
'displayGroupId' => $params->getInt('displayGroupId'),
],
]);
$body = json_decode($response->getBody()->getContents(), true);
if (!$body) {
throw new GeneralException(__('No response'));
}
// Set the displays
$this->setDisplaysForDma($body['_id'], $params->getInt('displayGroupId'));
return $body;
} catch (\Exception $e) {
$this->handleException($e);
}
}
/**
* @throws \Xibo\Support\Exception\InvalidArgumentException
* @throws \Xibo\Support\Exception\GeneralException
*/
public function dmaDelete(SanitizerInterface $params)
{
try {
$this->getClient()->delete($this->getServiceUrl() . '/dma/' . $params->getString('_id'), [
'timeout' => 120,
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey'),
],
]);
return null;
} catch (\Exception $e) {
$this->handleException($e);
}
}
// </editor-fold>
/**
* @throws \Xibo\Support\Exception\InvalidArgumentException
* @throws \Xibo\Support\Exception\GeneralException
*/
public function getOptionsFromAxe($apiKey = null, $throw = false)
{
$apiKey = $apiKey ?? $this->getSetting('apiKey');
if (empty($apiKey)) {
if ($throw) {
throw new InvalidArgumentException(__('Please provide an API key'));
} else {
return [
'error' => true,
'message' => __('Please provide an API key'),
];
}
}
try {
$response = $this->getClient()->get($this->getServiceUrl() . '/options', [
'timeout' => 120,
'headers' => [
'X-API-KEY' => $apiKey,
],
]);
return json_decode($response->getBody()->getContents(), true);
} catch (\Exception $e) {
try {
$this->handleException($e);
} catch (\Exception $exception) {
if ($throw) {
throw $exception;
} else {
return [
'error' => true,
'message' => $exception->getMessage() ?: __('Unknown Error'),
];
}
}
}
}
private function setDisplaysForDma($dmaId, $displayGroupId)
{
// Get displays
$displayIds = [];
foreach ($this->displayFactory->getByDisplayGroupId($displayGroupId) as $display) {
$displayIds[] = $display->displayId;
}
// Make a blind call to update this DMA.
try {
$this->getClient()->post($this->getServiceUrl() . '/dma/' . $dmaId . '/displays', [
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'json' => [
'displays' => $displayIds,
]
]);
} catch (\Exception $e) {
$this->getLogger()->error('Exception updating Displays for dmaId: ' . $dmaId
. ', e: ' . $e->getMessage());
}
}
/**
* @param \Exception $exception
* @return void
* @throws \Xibo\Support\Exception\GeneralException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
private function handleException($exception)
{
$this->getLogger()->debug('handleException: ' . $exception->getMessage());
$this->getLogger()->debug('handleException: ' . $exception->getTraceAsString());
if ($exception instanceof ClientException) {
if ($exception->hasResponse()) {
$body = $exception->getResponse()->getBody() ?? null;
if (!empty($body)) {
$decodedBody = json_decode($body, true);
$message = $decodedBody['message'] ?? $body;
} else {
$message = __('An unknown error has occurred.');
}
switch ($exception->getResponse()->getStatusCode()) {
case 422:
throw new InvalidArgumentException($message);
case 404:
throw new NotFoundException($message);
case 401:
throw new AccessDeniedException(__('Access denied, please check your API key'));
default:
throw new GeneralException(sprintf(
__('Unknown client exception processing your request, error code is %s'),
$exception->getResponse()->getStatusCode()
));
}
} else {
throw new InvalidArgumentException(__('Invalid request'));
}
} elseif ($exception instanceof ServerException) {
$this->getLogger()->error('handleException:' . $exception->getMessage());
throw new GeneralException(__('There was a problem processing your request, please try again'));
} else {
throw new GeneralException(__('Unknown Error'));
}
}
}

View File

@@ -0,0 +1,551 @@
<?php
/*
* Copyright (C) 2023 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 GuzzleHttp\Exception\RequestException;
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Xibo\Event\DashboardDataRequestEvent;
use Xibo\Event\MaintenanceRegularEvent;
use Xibo\Event\WidgetEditOptionRequestEvent;
use Xibo\Event\XmdsConnectorFileEvent;
use Xibo\Event\XmdsConnectorTokenEvent;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Support\Sanitizer\SanitizerInterface;
/**
* Xibo Dashboard Service connector.
* This connector collects credentials and sends them off to the dashboard service
*/
class XiboDashboardConnector implements ConnectorInterface
{
use ConnectorTrait;
/** @var float|int The token TTL */
const TOKEN_TTL_SECONDS = 3600 * 24 * 2;
/** @var string Used when rendering the form */
private $errorMessage;
/** @var array Cache of available services */
private $availableServices = null;
/** @var string Cache key for credential states */
private $cacheKey = 'connector/xibo_dashboard_connector_statuses';
/** @var array Cache of error types */
private $cachedErrorTypes = null;
public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
{
$dispatcher->addListener(MaintenanceRegularEvent::$NAME, [$this, 'onRegularMaintenance']);
$dispatcher->addListener(XmdsConnectorFileEvent::$NAME, [$this, 'onXmdsFile']);
$dispatcher->addListener(XmdsConnectorTokenEvent::$NAME, [$this, 'onXmdsToken']);
$dispatcher->addListener(WidgetEditOptionRequestEvent::$NAME, [$this, 'onWidgetEditOption']);
$dispatcher->addListener(DashboardDataRequestEvent::$NAME, [$this, 'onDataRequest']);
return $this;
}
public function getSourceName(): string
{
return 'xibo-dashboard-connector';
}
public function getTitle(): string
{
return 'Xibo Dashboard Service';
}
public function getDescription(): string
{
return 'Add your dashboard credentials for use in the Dashboard widget.';
}
public function getThumbnail(): string
{
return 'theme/default/img/connectors/xibo-dashboards.png';
}
public function getSettingsFormTwig(): string
{
return 'xibo-dashboard-form-settings';
}
/**
* Get the service url, either from settings or a default
* @return string
*/
public function getServiceUrl(): string
{
return $this->getSetting('serviceUrl', 'https://api.dashboards.xibosignage.com');
}
/**
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
public function processSettingsForm(SanitizerInterface $params, array $settings): array
{
// Remember the old service URL
$existingApiKey = $this->getSetting('apiKey');
if (!$this->isProviderSetting('apiKey')) {
$settings['apiKey'] = $params->getString('apiKey');
}
// What if the user changes their API key?
// Handle existing credentials
if ($existingApiKey !== $settings['apiKey']) {
// Test the new API key.
$services = $this->getAvailableServices(true, $settings['apiKey']);
if (!is_array($services)) {
throw new InvalidArgumentException($services);
}
// The new key is valid, clear out the old key's credentials.
if (!empty($existingApiKey)) {
foreach ($this->getCredentials() as $type => $credential) {
try {
$this->getClient()->delete(
$this->getServiceUrl() . '/services/' . $type . '/' . $credential['id'],
[
'headers' => [
'X-API-KEY' => $existingApiKey
]
]
);
} catch (RequestException $requestException) {
$this->getLogger()->error('getAvailableServices: delete failed. e = '
. $requestException->getMessage());
}
}
}
$credentials = [];
} else {
$credentials = $this->getCredentials();
}
$this->getLogger()->debug('Processing credentials');
foreach ($this->getAvailableServices(false, $settings['apiKey']) as $service) {
// Pull in the parameters for this service.
$id = $params->getString($service['type'] . '_id');
$isMarkedForRemoval = $params->getCheckbox($service['type'] . '_remove') == 1;
if (empty($id)) {
$userName = $params->getString($service['type'] . '_userName');
} else {
$userName = $credentials[$service['type']]['userName'] ?? null;
// This shouldn't happen because we had it when the form opened.
if ($userName === null) {
$isMarkedForRemoval = true;
}
}
$password = $params->getParam($service['type'] . '_password');
$twoFactorSecret = $params->getString($service['type'] . '_twoFactorSecret');
$isUrl = isset($service['isUrl']);
$url = ($isUrl) ? $params->getString($service['type' ]. '_url') : '';
if (!empty($id) && $isMarkedForRemoval) {
// Existing credential marked for removal
try {
$this->getClient()->delete($this->getServiceUrl() . '/services/' . $service['type'] . '/' . $id, [
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
]
]);
} catch (RequestException $requestException) {
$this->getLogger()->error('getAvailableServices: delete failed. e = '
. $requestException->getMessage());
}
unset($credentials[$service['type']]);
} else if (!empty($userName) && !empty($password)) {
// A new service or an existing service with a changed password.
// Make a request to our service URL.
try {
$response = $this->getClient()->post(
$this->getServiceUrl() . '/services/' . $service['type'],
[
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'json' => [
'username' => $userName,
'password' => $password,
'totp' => $twoFactorSecret,
'url' => $url
],
'timeout' => 120
]
);
$json = json_decode($response->getBody()->getContents(), true);
if (empty($json)) {
throw new InvalidArgumentException(__('Empty response from the dashboard service'), $service['type']);
}
$credentialId = $json['id'];
$credentials[$service['type']] = [
'userName' => $userName,
'id' => $credentialId,
'status' => true
];
} catch (RequestException $requestException) {
$this->getLogger()->error('getAvailableServices: e = ' . $requestException->getMessage());
throw new InvalidArgumentException(__('Cannot register those credentials.'), $service['type']);
}
}
}
// Set the credentials
$settings['credentials'] = $credentials;
return $settings;
}
public function getCredentialForType(string $type)
{
return $this->settings['credentials'][$type] ?? null;
}
public function getCredentials(): array
{
return $this->settings['credentials'] ?? [];
}
/**
* Used by the Twig template
* @param string $type
* @return bool
*/
public function isCredentialInErrorState(string $type): bool
{
if ($this->cachedErrorTypes === null) {
$item = $this->getPool()->getItem($this->cacheKey);
if ($item->isHit()) {
$this->cachedErrorTypes = $item->get();
} else {
$this->cachedErrorTypes = [];
}
}
return in_array($type, $this->cachedErrorTypes);
}
/**
* @return array|mixed|string|null
*/
public function getAvailableServices(bool $isReturnError = true, ?string $withApiKey = null)
{
if ($withApiKey) {
$apiKey = $withApiKey;
} else {
$apiKey = $this->getSetting('apiKey');
if (empty($apiKey)) {
return [];
}
}
if ($this->availableServices === null) {
$this->getLogger()->debug('getAvailableServices: Requesting available services.');
try {
$response = $this->getClient()->get($this->getServiceUrl() . '/services', [
'headers' => [
'X-API-KEY' => $apiKey
]
]);
$body = $response->getBody()->getContents();
$this->getLogger()->debug('getAvailableServices: ' . $body);
$json = json_decode($body, true);
if (empty($json)) {
throw new InvalidArgumentException(__('Empty response from the dashboard service'));
}
$this->availableServices = $json;
} catch (RequestException $e) {
$this->getLogger()->error('getAvailableServices: e = ' . $e->getMessage());
$message = json_decode($e->getResponse()->getBody()->getContents(), true);
if ($isReturnError) {
return empty($message)
? __('Cannot contact dashboard service, please try again shortly.')
: $message['message'];
} else {
return [];
}
} catch (\Exception $e) {
$this->getLogger()->error('getAvailableServices: e = ' . $e->getMessage());
if ($isReturnError) {
return __('Cannot contact dashboard service, please try again shortly.');
} else {
return [];
}
}
}
return $this->availableServices;
}
public function onRegularMaintenance(MaintenanceRegularEvent $event)
{
$this->getLogger()->debug('onRegularMaintenance');
$credentials = $this->getCredentials();
if (count($credentials) <= 0) {
$this->getLogger()->debug('onRegularMaintenance: No credentials configured, nothing to do.');
return;
}
$services = [];
foreach ($credentials as $credential) {
// Build up a request to ping the service.
$services[] = $credential['id'];
}
try {
$response = $this->getClient()->post(
$this->getServiceUrl() . '/services',
[
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey')
],
'json' => $services
]
);
$body = $response->getBody()->getContents();
if (empty($body)) {
throw new NotFoundException('Empty response');
}
$json = json_decode($body, true);
if (!is_array($json)) {
throw new GeneralException('Invalid response body: ' . $body);
}
// Parse the response and activate/deactivate services accordingly.
$erroredTypes = [];
foreach ($credentials as $type => $credential) {
// Get this service from the response.
foreach ($json as $item) {
if ($item['id'] === $credential['id']) {
if ($item['status'] !== true) {
$this->getLogger()->error($type . ' credential is in error state');
$erroredTypes[] = $type;
}
continue 2;
}
}
$erroredTypes[] = $type;
$this->getLogger()->error($type . ' credential is not present');
}
// Cache the errored types.
if (count($erroredTypes) > 0) {
$item = $this->getPool()->getItem($this->cacheKey);
$item->set($erroredTypes);
$item->expiresAfter(3600 * 4);
$this->getPool()->save($item);
} else {
$this->getPool()->deleteItem($this->cacheKey);
}
} catch (\Exception $e) {
$event->addMessage(__('Error calling Dashboard service'));
$this->getLogger()->error('onRegularMaintenance: dashboard service e = ' . $e->getMessage());
}
}
public function onXmdsToken(XmdsConnectorTokenEvent $event)
{
$this->getLogger()->debug('onXmdsToken');
// We are either generating a new token, or verifying an old one.
if (empty($event->getToken())) {
$this->getLogger()->debug('onXmdsToken: empty token, generate a new one');
// Generate a new token
$token = $this->getJwtService()->generateJwt(
$this->getTitle(),
$this->getSourceName(),
$event->getWidgetId(),
$event->getDisplayId(),
$event->getTtl()
);
$event->setToken($token->toString());
} else {
$this->getLogger()->debug('onXmdsToken: Validate the token weve been given');
try {
$token = $this->getJwtService()->validateJwt($event->getToken());
if ($token === null) {
throw new NotFoundException(__('Cannot decode token'));
}
if ($this->getSourceName() === $token->claims()->get('aud')) {
$this->getLogger()->debug('onXmdsToken: Token not for this connector');
return;
}
// Configure the event with details from this token
$displayId = intval($token->claims()->get('sub'));
$widgetId = intval($token->claims()->get('jti'));
$event->setTargets($displayId, $widgetId);
$this->getLogger()->debug('onXmdsToken: Configured event with displayId: ' . $displayId
. ', widgetId: ' . $widgetId);
} catch (\Exception $exception) {
$this->getLogger()->error('onXmdsToken: Invalid token, e = ' . $exception->getMessage());
}
}
}
public function onXmdsFile(XmdsConnectorFileEvent $event)
{
$this->getLogger()->debug('onXmdsFile');
try {
// Get the widget
$widget = $event->getWidget();
if ($widget === null) {
throw new NotFoundException();
}
// We want options, so load the widget
$widget->load();
$type = $widget->getOptionValue('type', 'powerbi');
// Get the credentials for this type.
$credentials = $this->getCredentialForType($type);
if ($credentials === null) {
throw new NotFoundException(sprintf(__('No credentials logged for %s'), $type));
}
// Add headers
$headers = [
'X-API-KEY' => $this->getSetting('apiKey')
];
$response = $this->getClient()->get($this->getServiceUrl() . '/services/' . $type, [
'headers' => $headers,
'query' => [
'credentialId' => $credentials['id'],
'url' => $widget->getOptionValue('url', ''),
'interval' => $widget->getOptionValue('updateInterval', 60) * 60,
'debug' => $event->isDebug()
]
]);
// Create a response
$factory = new Psr17Factory();
$event->setResponse(
$factory->createResponse(200)
->withHeader('Content-Type', $response->getHeader('Content-Type'))
->withHeader('Cache-Control', $response->getHeader('Cache-Control'))
->withHeader('Last-Modified', $response->getHeader('Last-Modified'))
->withBody($response->getBody())
);
} catch (\Exception $exception) {
// We log any error and return empty
$this->getLogger()->error('onXmdsFile: unknown error: ' . $exception->getMessage());
}
}
public function onWidgetEditOption(WidgetEditOptionRequestEvent $event)
{
$this->getLogger()->debug('onWidgetEditOption');
// Pull the widget we're working with.
$widget = $event->getWidget();
if ($widget === null) {
throw new NotFoundException();
}
// Pull in existing information
$existingType = $event->getPropertyValue();
$options = $event->getOptions();
// We handle the dashboard widget and the property with id="type"
if ($widget->type === 'dashboard' && $event->getPropertyId() === 'type') {
// get available services
$services = $this->getAvailableServices(true, $this->getSetting('apiKey'));
foreach ($services as $option) {
// Filter the list of options by the property value provided (if there is one).
if (empty($existingType) || $option['type'] === $existingType) {
$options[] = $option;
}
}
// Set these options on the event.
$event->setOptions($options);
}
}
public function onDataRequest(DashboardDataRequestEvent $event, $eventName, EventDispatcherInterface $dispatcher)
{
$this->getLogger()->debug('onDataRequest');
// Validate that we're configured.
if (empty($this->getSetting('apiKey'))) {
$event->getDataProvider()->addError(__('Dashboard Connector not configured'));
return;
}
// Always generate a token
try {
$tokenEvent = new XmdsConnectorTokenEvent();
$tokenEvent->setTargets($event->getDataProvider()->getDisplayId(), $event->getDataProvider()->getWidgetId());
$tokenEvent->setTtl(self::TOKEN_TTL_SECONDS);
$dispatcher->dispatch($tokenEvent, XmdsConnectorTokenEvent::$NAME);
$token = $tokenEvent->getToken();
if (empty($token)) {
$event->getDataProvider()->addError(__('No token returned'));
return;
}
} catch (\Exception $e) {
$this->getLogger()->error('onDataRequest: Failed to get token. e = ' . $e->getMessage());
$event->getDataProvider()->addError(__('No token returned'));
return;
}
// We return a single data item which contains our URL, token and whether we're a preview
$item = [];
$item['url'] = $this->getTokenUrl($token);
$item['token'] = $token;
$item['isPreview'] = $event->getDataProvider()->isPreview();
// We make sure our data cache expires shortly before the token itself expires (so that we have a new token
// generated for it).
$event->getDataProvider()->setCacheTtl(self::TOKEN_TTL_SECONDS - 3600);
// Add our item and set handled
$event->getDataProvider()->addItem($item);
$event->getDataProvider()->setIsHandled();
}
}

View File

@@ -0,0 +1,265 @@
<?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 GuzzleHttp\Client;
use Illuminate\Support\Str;
use Parsedown;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Xibo\Entity\SearchResult;
use Xibo\Event\TemplateProviderEvent;
use Xibo\Event\TemplateProviderImportEvent;
use Xibo\Event\TemplateProviderListEvent;
use Xibo\Support\Sanitizer\SanitizerInterface;
/**
* XiboExchangeConnector
* ---------------------
* This connector will consume the Xibo Layout Exchange API and offer pre-built templates for selection when adding
* a new layout.
*/
class XiboExchangeConnector implements ConnectorInterface
{
use ConnectorTrait;
/**
* @param EventDispatcherInterface $dispatcher
* @return ConnectorInterface
*/
public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
{
$dispatcher->addListener('connector.provider.template', [$this, 'onTemplateProvider']);
$dispatcher->addListener('connector.provider.template.import', [$this, 'onTemplateProviderImport']);
$dispatcher->addListener('connector.provider.template.list', [$this, 'onTemplateList']);
return $this;
}
public function getSourceName(): string
{
return 'xibo-exchange';
}
public function getTitle(): string
{
return 'Xibo Exchange';
}
public function getDescription(): string
{
return 'Show Templates provided by the Xibo Exchange in the add new Layout form.';
}
public function getThumbnail(): string
{
return 'theme/default/img/connectors/xibo-exchange.png';
}
public function getSettingsFormTwig(): string
{
return 'connector-form-edit';
}
public function processSettingsForm(SanitizerInterface $params, array $settings): array
{
return $settings;
}
/**
* Get layouts available in Layout exchange and add them to the results
* This is triggered in Template Controller search function
* @param TemplateProviderEvent $event
*/
public function onTemplateProvider(TemplateProviderEvent $event)
{
$this->getLogger()->debug('XiboExchangeConnector: onTemplateProvider');
// Get a cache of the layouts.json file, or request one from download.
$uri = 'https://download.xibosignage.com/layouts_v4_1.json';
$key = md5($uri);
$cache = $this->getPool()->getItem($key);
$body = $cache->get();
if ($cache->isMiss()) {
$this->getLogger()->debug('onTemplateProvider: cache miss, generating.');
// Make the request
$request = $this->getClient()->request('GET', $uri);
$body = $request->getBody()->getContents();
if (empty($body)) {
$this->getLogger()->debug('onTemplateProvider: Empty body');
return;
}
$body = json_decode($body);
if ($body === null || $body === false) {
$this->getLogger()->debug('onTemplateProvider: non-json body or empty body returned.');
return;
}
// Cache for next time
$cache->set($body);
$cache->expiresAt(Carbon::now()->addHours(24));
$this->getPool()->saveDeferred($cache);
} else {
$this->getLogger()->debug('onTemplateProvider: serving from cache.');
}
// We have the whole file locally, so handle paging
$start = $event->getStart();
$perPage = $event->getLength();
// Create a provider to add to each search result
$providerDetails = new ProviderDetails();
$providerDetails->id = $this->getSourceName();
$providerDetails->logoUrl = $this->getThumbnail();
$providerDetails->iconUrl = $this->getThumbnail();
$providerDetails->message = $this->getTitle();
$providerDetails->backgroundColor = '';
// parse the templates based on orientation filter.
if (!empty($event->getOrientation())) {
$templates = [];
foreach ($body as $template) {
if (!empty($template->orientation) &&
Str::contains($template->orientation, $event->getOrientation(), true)
) {
$templates[] = $template;
}
}
} else {
$templates = $body;
}
// Filter the body based on search param.
if (!empty($event->getSearch())) {
$filtered = [];
foreach ($templates as $template) {
if (Str::contains($template->title, $event->getSearch(), true)) {
$filtered[] = $template;
continue;
}
if (!empty($template->description) &&
Str::contains($template->description, $event->getSearch(), true)
) {
$filtered[] = $template;
continue;
}
if (property_exists($template, 'tags') && count($template->tags) > 0) {
if (in_array($event->getSearch(), $template->tags)) {
$filtered[] = $template;
}
}
}
} else {
$filtered = $templates;
}
// sort, featured first, otherwise alphabetically.
usort($filtered, function ($a, $b) {
if (property_exists($a, 'isFeatured') && property_exists($b, 'isFeatured')) {
return $b->isFeatured <=> $a->isFeatured;
} else {
return $a->title <=> $b->title;
}
});
for ($i = $start; $i < ($start + $perPage - 1) && $i < count($filtered); $i++) {
$searchResult = $this->createSearchResult($filtered[$i]);
$searchResult->provider = $providerDetails;
$event->addResult($searchResult);
}
}
/**
* When remote source Template is selected on Layout add,
* we need to get the zip file from specified url and import it to the CMS
* imported Layout object is set on the Event and retrieved later in Layout controller
* @param TemplateProviderImportEvent $event
*/
public function onTemplateProviderImport(TemplateProviderImportEvent $event)
{
$downloadUrl = $event->getDownloadUrl();
$client = new Client();
$tempFile = $event->getLibraryLocation() . 'temp/' . $event->getFileName();
$client->request('GET', $downloadUrl, ['sink' => $tempFile]);
$event->setFilePath($tempFile);
}
/**
* @param $template
* @return SearchResult
*/
private function createSearchResult($template) : SearchResult
{
$searchResult = new SearchResult();
$searchResult->id = $template->fileName;
$searchResult->source = 'remote';
$searchResult->title = $template->title;
$searchResult->description = empty($template->description)
? null
: Parsedown::instance()->setSafeMode(true)->line($template->description);
// Optional data
if (property_exists($template, 'tags') && count($template->tags) > 0) {
$searchResult->tags = $template->tags;
}
if (property_exists($template, 'orientation')) {
$searchResult->orientation = $template->orientation;
}
if (property_exists($template, 'isFeatured')) {
$searchResult->isFeatured = $template->isFeatured;
}
// Thumbnail
$searchResult->thumbnail = $template->thumbnailUrl;
$searchResult->download = $template->downloadUrl;
return $searchResult;
}
/**
* Add this connector to the list of providers.
* @param \Xibo\Event\TemplateProviderListEvent $event
* @return void
*/
public function onTemplateList(TemplateProviderListEvent $event): void
{
$this->getLogger()->debug('onTemplateList:event');
$providerDetails = new ProviderDetails();
$providerDetails->id = $this->getSourceName();
$providerDetails->link = 'https://xibosignage.com';
$providerDetails->logoUrl = $this->getThumbnail();
$providerDetails->iconUrl = 'exchange-alt';
$providerDetails->message = $this->getTitle();
$providerDetails->backgroundColor = '';
$providerDetails->mediaTypes = ['xlf'];
$event->addProvider($providerDetails);
}
}

View File

@@ -0,0 +1,582 @@
<?php
/*
* Copyright (C) 2025 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 GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Str;
use Psr\Container\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Xibo\Event\MaintenanceRegularEvent;
use Xibo\Event\WidgetEditOptionRequestEvent;
use Xibo\Service\ConfigServiceInterface;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Support\Sanitizer\SanitizerInterface;
/**
* Xibo SSP Connector
* communicates with the Xibo Ad Exchange to register displays with connected SSPs and manage ad requests
*/
class XiboSspConnector implements ConnectorInterface
{
use ConnectorTrait;
/** @var string */
private $formError;
/** @var array */
private $partners;
/** @var \Xibo\Factory\DisplayFactory */
private $displayFactory;
/**
* @param \Psr\Container\ContainerInterface $container
* @return \Xibo\Connector\ConnectorInterface
*/
public function setFactories(ContainerInterface $container): ConnectorInterface
{
$this->displayFactory = $container->get('displayFactory');
return $this;
}
public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
{
$dispatcher->addListener(MaintenanceRegularEvent::$NAME, [$this, 'onRegularMaintenance']);
$dispatcher->addListener(WidgetEditOptionRequestEvent::$NAME, [$this, 'onWidgetEditOption']);
return $this;
}
public function getSourceName(): string
{
return 'xibo-ssp-connector';
}
public function getTitle(): string
{
return 'Xibo SSP Connector';
}
public function getDescription(): string
{
return 'Connect to world leading Supply Side Platforms (SSPs) and monetise your network.';
}
public function getThumbnail(): string
{
return 'theme/default/img/connectors/xibo-ssp.png';
}
public function getSettingsFormTwig(): string
{
return 'xibo-ssp-connector-form-settings';
}
public function getSettingsFormJavaScript(): string
{
return 'xibo-ssp-connector-form-javascript';
}
public function getFormError(): string
{
return $this->formError ?? __('Unknown error');
}
public function processSettingsForm(SanitizerInterface $params, array $settings): array
{
$existingApiKey = $this->getSetting('apiKey');
if (!$this->isProviderSetting('apiKey')) {
$settings['apiKey'] = $params->getString('apiKey');
}
$existingCmsUrl = $this->getSetting('cmsUrl');
if (!$this->isProviderSetting('cmsUrl')) {
$settings['cmsUrl'] = trim($params->getString('cmsUrl'), '/');
if (empty($settings['cmsUrl']) || !Str::startsWith($settings['cmsUrl'], 'http')) {
throw new InvalidArgumentException(
__('Please enter a CMS URL, including http(s)://'),
'cmsUrl'
);
}
}
// If our API key was empty, then do not set partners.
if (empty($existingApiKey) || empty($settings['apiKey'])) {
return $settings;
}
// Set partners.
$partners = [];
$available = $this->getAvailablePartners(true, $settings['apiKey']);
// Pull in expected fields.
foreach ($available as $partnerId => $partner) {
$partners[] = [
'name' => $partnerId,
'enabled' => $params->getCheckbox($partnerId . '_enabled'),
'isTest' => $params->getCheckbox($partnerId . '_isTest'),
'isUseWidget' => $params->getCheckbox($partnerId . '_isUseWidget'),
'currency' => $params->getString($partnerId . '_currency'),
'key' => $params->getString($partnerId . '_key'),
'sov' => $params->getInt($partnerId . '_sov'),
'mediaTypesAllowed' => $params->getString($partnerId . '_mediaTypesAllowed'),
'duration' => $params->getInt($partnerId . '_duration'),
'minDuration' => $params->getInt($partnerId . '_minDuration'),
'maxDuration' => $params->getInt($partnerId . '_maxDuration'),
];
// Also grab the displayGroupId if one has been set.
$displayGroupId = $params->getInt($partnerId . '_displayGroupId');
if (empty($displayGroupId)) {
unset($settings[$partnerId . '_displayGroupId']);
} else {
$settings[$partnerId . '_displayGroupId'] = $displayGroupId;
}
$settings[$partnerId . '_sspIdField'] = $params->getString($partnerId . '_sspIdField');
}
// Update API config.
$this->setPartners($settings['apiKey'], $partners);
try {
// If the API key has changed during this request, clear out displays on the old API key
if ($existingApiKey !== $settings['apiKey']) {
// Clear all displays for this CMS on the existing key
$this->setDisplays($existingApiKey, $existingCmsUrl, [], $settings);
} else if (!empty($existingCmsUrl) && $existingCmsUrl !== $settings['cmsUrl']) {
// Clear all displays for this CMS on the existing key
$this->setDisplays($settings['apiKey'], $existingCmsUrl, [], $settings);
}
} catch (\Exception $e) {
$this->getLogger()->error('Failed to set displays '. $e->getMessage());
}
// Add displays on the new API key (maintenance also does this, but do it now).
$this->setDisplays($settings['apiKey'], $settings['cmsUrl'], $partners, $settings);
return $settings;
}
/**
* @throws InvalidArgumentException
* @throws GeneralException
*/
public function getAvailablePartners(bool $isThrowError = false, ?string $withApiKey = null)
{
if ($this->partners === null) {
// Make a call to the API to see what we've currently got configured and what is available.
if ($withApiKey) {
$apiKey = $withApiKey;
} else {
$apiKey = $this->getSetting('apiKey');
if (empty($apiKey)) {
return [];
}
}
$this->getLogger()->debug('getAvailablePartners: Requesting available services.');
try {
$response = $this->getClient()->get($this->getServiceUrl() . '/configure', [
'headers' => [
'X-API-KEY' => $apiKey
]
]);
$body = $response->getBody()->getContents();
$this->getLogger()->debug('getAvailablePartners: ' . $body);
$json = json_decode($body, true);
if (empty($json)) {
$this->formError = __('Empty response from the dashboard service');
throw new InvalidArgumentException($this->formError);
}
$this->partners = $json;
} catch (RequestException $e) {
$this->getLogger()->error('getAvailablePartners: e = ' . $e->getMessage());
if ($e->getResponse()->getStatusCode() === 401) {
$this->formError = __('API key not valid');
if ($isThrowError) {
throw new InvalidArgumentException($this->formError, 'apiKey');
} else {
return null;
}
}
$message = json_decode($e->getResponse()->getBody()->getContents(), true);
$this->formError = empty($message)
? __('Cannot contact SSP service, please try again shortly.')
: $message['message'];
if ($isThrowError) {
throw new GeneralException($this->formError);
} else {
return null;
}
} catch (\Exception $e) {
$this->getLogger()->error('getAvailableServices: e = ' . $e->getMessage());
$this->formError = __('Cannot contact SSP service, please try again shortly.');
if ($isThrowError) {
throw new GeneralException($this->formError);
} else {
return null;
}
}
}
return $this->partners['available'] ?? [];
}
/**
* Get the number of displays that are authorised by this API key.
* @return int
*/
public function getAuthorisedDisplayCount(): int
{
return intval($this->partners['displays'] ?? 0);
}
/**
* Get a setting for a partner
* @param string $partnerKey
* @param string $setting
* @param $default
* @return mixed|string|null
*/
public function getPartnerSetting(string $partnerKey, string $setting, $default = null)
{
if (!is_array($this->partners) || !array_key_exists('partners', $this->partners)) {
return $default;
}
foreach ($this->partners['partners'] as $partner) {
if ($partner['name'] === $partnerKey) {
return $partner[$setting] ?? $default;
}
}
return $default;
}
/**
* @throws InvalidArgumentException
* @throws GeneralException
*/
private function setPartners(string $apiKey, array $partners)
{
$this->getLogger()->debug('setPartners: updating');
$this->getLogger()->debug(json_encode($partners));
try {
$this->getClient()->post($this->getServiceUrl() . '/configure', [
'headers' => [
'X-API-KEY' => $apiKey
],
'json' => [
'partners' => $partners
]
]);
} catch (RequestException $e) {
$this->getLogger()->error('setPartners: e = ' . $e->getMessage());
$message = json_decode($e->getResponse()->getBody()->getContents(), true);
throw new GeneralException(empty($message)
? __('Cannot contact SSP service, please try again shortly.')
: $message['message']);
} catch (\Exception $e) {
$this->getLogger()->error('setPartners: e = ' . $e->getMessage());
throw new GeneralException(__('Cannot contact SSP service, please try again shortly.'));
}
}
/**
* @throws \Xibo\Support\Exception\NotFoundException
* @throws \Xibo\Support\Exception\GeneralException
*/
private function setDisplays(string $apiKey, string $cmsUrl, array $partners, array $settings)
{
$displays = [];
foreach ($partners as $partner) {
// If this partner is enabled?
if (!$partner['enabled']) {
continue;
}
// Get displays for this partner
$partnerKey = $partner['name'];
$sspIdField = $settings[$partnerKey . '_sspIdField'] ?? 'displayId';
foreach ($this->displayFactory->query(null, [
'disableUserCheck' => 1,
'displayGroupId' => $settings[$partnerKey . '_displayGroupId'] ?? null,
'authorised' => 1,
]) as $display) {
if (!array_key_exists($display->displayId, $displays)) {
$resolution = explode('x', $display->resolution ?? '');
$displays[$display->displayId] = [
'displayId' => $display->displayId,
'hardwareKey' => $display->license,
'width' => trim($resolution[0] ?? 1920),
'height' => trim($resolution[1] ?? 1080),
'partners' => [],
];
}
switch ($sspIdField) {
case 'customId':
$sspId = $display->customId;
break;
case 'ref1':
$sspId = $display->ref1;
break;
case 'ref2':
$sspId = $display->ref2;
break;
case 'ref3':
$sspId = $display->ref3;
break;
case 'ref4':
$sspId = $display->ref4;
break;
case 'ref5':
$sspId = $display->ref5;
break;
case 'displayId':
default:
$sspId = $display->displayId;
}
$displays[$display->displayId]['partners'][] = [
'name' => $partnerKey,
'sspId' => '' . $sspId,
];
}
}
try {
$this->getClient()->post($this->getServiceUrl() . '/displays', [
'headers' => [
'X-API-KEY' => $apiKey,
],
'json' => [
'cmsUrl' => $cmsUrl,
'displays' => array_values($displays),
],
]);
} catch (RequestException $e) {
$this->getLogger()->error('setDisplays: e = ' . $e->getMessage());
$message = json_decode($e->getResponse()->getBody()->getContents(), true);
throw new GeneralException(empty($message)
? __('Cannot contact SSP service, please try again shortly.')
: $message['message']);
} catch (\Exception $e) {
$this->getLogger()->error('setDisplays: e = ' . $e->getMessage());
throw new GeneralException(__('Cannot contact SSP service, please try again shortly.'));
}
}
/**
* Get the service url, either from settings or a default
* @return string
*/
private function getServiceUrl(): string
{
return $this->getSetting('serviceUrl', 'https://exchange.xibo-adspace.com/api');
}
// <editor-fold desc="Proxy methods">
/**
* Activity data
*/
public function activity(SanitizerInterface $params): array
{
$fromDt = $params->getDate('activityFromDt', [
'default' => Carbon::now()->startOfHour()
]);
$toDt = $params->getDate('activityToDt', [
'default' => $fromDt->addHour()
]);
if ($params->getInt('displayId') == null) {
throw new GeneralException(__('Display ID is required'));
}
// Call the api (override the timeout)
try {
$response = $this->getClient()->get($this->getServiceUrl() . '/activity', [
'timeout' => 120,
'headers' => [
'X-API-KEY' => $this->getSetting('apiKey'),
],
'query' => [
'cmsUrl' => $this->getSetting('cmsUrl'),
'fromDt' => $fromDt->toAtomString(),
'toDt' => $toDt->toAtomString(),
'displayId' => $params->getInt('displayId'),
'campaignId' => $params->getString('partnerId'),
],
]);
$body = json_decode($response->getBody()->getContents(), true);
if (!$body) {
throw new GeneralException(__('No response'));
}
return $body;
} catch (\Exception $e) {
$this->getLogger()->error('activity: e = ' . $e->getMessage());
}
return [
'data' => [],
'recordsTotal' => 0,
];
}
/**
* Available Partners
*/
public function getAvailablePartnersFilter(SanitizerInterface $params): array
{
try {
return $this->getAvailablePartners() ?? [];
} catch (\Exception $e) {
$this->getLogger()->error('activity: e = ' . $e->getMessage());
}
return [
'data' => [],
'recordsTotal' => 0,
];
}
// </editor-fold>
// <editor-fold desc="Listeners">
public function onRegularMaintenance(MaintenanceRegularEvent $event)
{
$this->getLogger()->debug('onRegularMaintenance');
try {
$this->getAvailablePartners();
$partners = $this->partners['partners'] ?? [];
if (count($partners) > 0) {
$this->setDisplays(
$this->getSetting('apiKey'),
$this->getSetting('cmsUrl'),
$partners,
$this->settings
);
}
$event->addMessage('SSP: done');
} catch (\Exception $exception) {
$this->getLogger()->error('SSP connector: ' . $exception->getMessage());
$event->addMessage('Error processing SSP configuration.');
}
}
/**
* Connector is being deleted
* @param \Xibo\Service\ConfigServiceInterface $configService
* @return void
*/
public function delete(ConfigServiceInterface $configService): void
{
$this->getLogger()->debug('delete');
$configService->changeSetting('isAdspaceEnabled', 0);
}
/**
* Connector is being enabled
* @param \Xibo\Service\ConfigServiceInterface $configService
* @return void
*/
public function enable(ConfigServiceInterface $configService): void
{
$this->getLogger()->debug('enable');
$configService->changeSetting('isAdspaceEnabled', 1);
}
/**
* Connector is being disabled
* @param \Xibo\Service\ConfigServiceInterface $configService
* @return void
*/
public function disable(ConfigServiceInterface $configService): void
{
$this->getLogger()->debug('disable');
$configService->changeSetting('isAdspaceEnabled', 0);
}
public function onWidgetEditOption(WidgetEditOptionRequestEvent $event)
{
$this->getLogger()->debug('onWidgetEditOption');
// Pull the widget we're working with.
$widget = $event->getWidget();
if ($widget === null) {
throw new NotFoundException();
}
// We handle the dashboard widget and the property with id="type"
if ($widget->type === 'ssp' && $event->getPropertyId() === 'partnerId') {
// Pull in existing information
$partnerFilter = $event->getPropertyValue();
$options = $event->getOptions();
foreach ($this->getAvailablePartners() as $partnerId => $partner) {
if ((empty($partnerFilter) || $partnerId === $partnerFilter)
&& $this->getPartnerSetting($partnerId, 'enabled') == 1
) {
$options[] = [
'id' => $partnerId,
'type' => $partnerId,
'name' => $partner['name'],
];
}
}
$event->setOptions($options);
}
}
// </editor-fold>
}