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