signageos-2.3.x-dev/src/Plugin/DigitalSignagePlatform/SignageOs.php
src/Plugin/DigitalSignagePlatform/SignageOs.php
<?php
namespace Drupal\signageos\Plugin\DigitalSignagePlatform;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\TempStore\TempStoreException;
use Drupal\Core\Url;
use Drupal\digital_signage_framework\DeviceInterface;
use Drupal\digital_signage_framework\Entity\Device;
use Drupal\digital_signage_framework\PlatformPluginBase;
use GuzzleHttp\Exception\GuzzleException;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the digital_signage_platform.
*
* @DigitalSignagePlatform(
* id = "signageos",
* label = @Translation("signageOS"),
* description = @Translation("Integrates into signageOS platform.")
* )
*/
class SignageOs extends PlatformPluginBase {
/**
* The signageOS API endpoint.
*/
public const BASE_URL = 'https://api.signageos.io/v1/';
/**
* The applet name.
*/
public const APPLET_NAME = 'Drupal';
/**
* The applet version.
*/
public const APPLET_VERSION = '1.2.2';
/**
* The signageOS applet version to be included.
*/
public const FRONT_APPLET_VERSION = '4.10.4';
/**
* The default device orientation.
*/
public const DEFAULT_ORIENTATION = 'LANDSCAPE';
/**
* The default device resolution.
*/
public const DEFAULT_RESOLUTION = 'FULL_HD';
/**
* Mapping of supported resolutions.
*/
public const RESOLUTIONS = [
self::DEFAULT_RESOLUTION => [
'width' => 1920,
'height' => 1080,
],
'HD_READY' => [
'width' => 1080,
'height' => 720,
],
];
/**
* Remote indicator for portrait orientation.
*/
public const PORTRAIT_QUERY = 'PORTRAIT';
/**
* The API client ID.
*
* @var string
*/
protected string $clientId;
/**
* The API client secret.
*
* @var string
*/
protected string $clientSecret;
/**
* The welcome message on remote screens.
*
* @var string
*/
protected string $welcomeMsg;
/**
* The background color for startup screen.
*
* @var string
*/
protected string $bgColor;
/**
* The foreground color for startup screen.
*
* @var string
*/
protected string $fgColor;
/**
* The module list service.
*
* @var \Drupal\Core\Extension\ModuleExtensionList
*/
protected ModuleExtensionList $moduleList;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
$instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
$instance->moduleList = $container->get('extension.list.module');
return $instance;
}
/**
* {@inheritdoc}
*/
public function init(): void {
$config = $this->configFactory->get('signageos.settings');
$this->clientId = $config->get('client_id') ?? '';
$this->clientSecret = $config->get('client_secret') ?? '';
$this->welcomeMsg = $config->get('welcome_msg') ?? 'Welcome to Digital Signage';
$this->bgColor = $config->get('bg_color') ?? '#cdf47e';
$this->fgColor = $config->get('fg_color') ?? '#333';
}
/**
* {@inheritdoc}
*/
public function scheduleBaseFields(array &$fields): void {
$fields['signageos_extid'] = BaseFieldDefinition::create('string')
->setRevisionable(FALSE)
->setTranslatable(FALSE)
->setLabel(t('External ID'))
->setDescription(t('The external ID of the schedule entity.'))
->setRequired(TRUE)
->setSetting('max_length', 255)
->setDisplayOptions('view', [
'label' => 'above',
'type' => 'string',
'weight' => -4,
])
->setDisplayConfigurable('view', TRUE);
}
/**
* {@inheritdoc}
*/
public function getPlatformDevices(): array {
$this->messenger->addStatus('Receiving signageOS devices');
$deviceEntities = [];
foreach ($this->request('device') as $device) {
$resolution = $this->getCurrentDeviceResolution($device);
if ($this->isPortraitOrientation($resolution)) {
$width = self::RESOLUTIONS[$resolution['resolution']]['height'];
$height = self::RESOLUTIONS[$resolution['resolution']]['width'];
}
else {
$width = self::RESOLUTIONS[$resolution['resolution']]['width'];
$height = self::RESOLUTIONS[$resolution['resolution']]['height'];
}
$deviceEntity = Device::create([
'bundle' => $this->getPluginId(),
'extid' => $device['uid'],
'title' => $device['name'],
'status' => TRUE,
'description' => $device['name'],
'size' => [
'width' => $width,
'height' => $height,
],
]);
$deviceEntities[] = $deviceEntity;
}
return $deviceEntities;
}
/**
* Helper function to receive remote timing setup.
*
* @param \Drupal\digital_signage_framework\DeviceInterface $device
* The device.
* @param string $appletUid
* The applet UID.
*
* @return array|null
* The matching timing or NULL, if none exists.
*/
private function loadTiming(DeviceInterface $device, string $appletUid): ?array {
foreach ($this->request('timing?deviceUid=' . $device->extId()) as $timing) {
if ($timing['appletUid'] === $appletUid && $timing['appletVersion'] === self::APPLET_VERSION) {
return $timing;
}
}
return NULL;
}
/**
* Helper function to store remote timing for a device.
*
* @param \Drupal\digital_signage_framework\DeviceInterface $device
* The device.
* @param array $timing
* The timing.
*/
private function saveTiming(DeviceInterface $device, array $timing): void {
$timingUid = $timing['uid'];
unset($timing['uid']);
$this->request('timing/' . $timingUid, 'put', $timing);
}
/**
* {@inheritdoc}
*/
public function pushSchedule(DeviceInterface $device, bool $debug, bool $reload_assets, bool $reload_content): void {
$at = $this->dateFormatter->format($this->time->getRequestTime(), 'html_datetime');
$configuration = $device->getApiSpec($debug, $reload_assets, $reload_content);
$appletUid = $this->getAppletUid();
if ($timing = $this->loadTiming($device, $appletUid)) {
if (md5(json_encode($configuration)) !== md5(json_encode($timing['configuration']))) {
$timing['configuration'] = $configuration;
$this->saveTiming($device, $timing);
}
$this->sendCommandUpdateConfiguration($device, $appletUid, TRUE, $configuration);
}
else {
$this->request('timing', 'post', [
'deviceUid' => $device->extId(),
'appletUid' => $this->pushApplet(),
'appletVersion' => self::APPLET_VERSION,
'configuration' => $configuration,
'startsAt' => $at,
'endsAt' => $at,
'position' => 1,
'finishEventType' => 'IDLE_TIMEOUT',
'finishEventData' => 1234567890,
]);
$this->sendPowerAction($device, 'APPLET_RELOAD');
}
}
/**
* {@inheritdoc}
*/
public function pushConfiguration(DeviceInterface $device, bool $debug, bool $reload_schedule, bool $reload_assets, bool $reload_content): void {
$configuration = $device->getApiSpec($debug, $reload_assets, $reload_content);
$appletUid = $this->getAppletUid();
$this->sendCommandUpdateConfiguration($device, $appletUid, $reload_schedule, $configuration);
}
/**
* {@inheritdoc}
*/
public function setEmergencyMode(DeviceInterface $device, string $entity_type, int $entity_id): void {
if ($appletUid = $this->getAppletUid(FALSE)) {
$this->request('device/' . $device->extId() . '/applet/' . $appletUid . '/command', 'post', [
'commandPayload' => [
'payload' => [
'type' => $entity_type,
'id' => $entity_id,
],
'type' => 'EmergencyModeSet',
],
]);
$timing = $this->loadTiming($device, $appletUid);
if ($timing) {
$timing['configuration']['emergencyEntity'] = [
'type' => $entity_type,
'id' => $entity_id,
];
$this->saveTiming($device, $timing);
}
}
}
/**
* {@inheritdoc}
*/
public function disableEmergencyMode(DeviceInterface $device): void {
if ($appletUid = $this->getAppletUid(FALSE)) {
$this->request('device/' . $device->extId() . '/applet/' . $appletUid . '/command', 'post', [
'commandPayload' => [
'payload' => [],
'type' => 'EmergencyModeDisable',
],
]);
$timing = $this->loadTiming($device, $appletUid);
if ($timing) {
$timing['configuration']['emergencyEntity'] = [];
$this->saveTiming($device, $timing);
}
}
}
/**
* Helper function to build and execute remote requests.
*
* @param string $path
* The patch.
* @param string $method
* The method.
* @param array $body
* The body values.
*
* @return array
* The decoded json response.
*/
protected function request(string $path, string $method = 'get', array $body = []): array {
$headers = [
'Cache-Control' => 'no-cache',
'Content-Type' => 'application/json',
'X-Auth' => implode(':', [
$this->clientId,
$this->clientSecret,
]),
];
$options['headers'] = $headers;
if (!empty($body)) {
$options['body'] = json_encode($body);
}
try {
$client = $this->clientFactory->fromOptions(['base_uri' => self::BASE_URL]);
$response = $client->request($method, $path, $options);
$statusCode = $response->getStatusCode();
if ($statusCode === 200) {
$content = $response->getBody()->getContents();
return json_decode($content, TRUE);
}
}
catch (GuzzleException) {
// @todo Log this exception.
}
return [];
}
/**
* Gets the resolution of the given device.
*
* @param array $device
* The device definition.
*
* @return array
* The width and height as an array.
*/
protected function getCurrentDeviceResolution(array $device): array {
$resolutions = $this->request('device/' . $device['uid'] . '/resolution');
return $this->getLatestSucceededResolution($resolutions);
}
/**
* Gets the latest resolution setting that was successful.
*
* @param array $resolutions
* An array of all known resolutions.
*
* @return array
* The current resolution.
*/
protected function getLatestSucceededResolution(array $resolutions): array {
$currentResolution = [
'resolution' => self::DEFAULT_RESOLUTION,
'orientation' => self::DEFAULT_ORIENTATION,
];
$latestTimestamp = -1;
foreach ($resolutions as $resolution) {
if ($resolution['succeededAt'] === NULL) {
continue;
}
if (strtotime($resolution['createdAt']) > $latestTimestamp) {
$latestTimestamp = strtotime($resolution['succeededAt']);
$currentResolution['resolution'] = $resolution['resolution'];
$currentResolution['orientation'] = $resolution['orientation'];
}
}
return $currentResolution;
}
/**
* Gets whether the orientation is portrait or not.
*
* @param array $resolution
* The resolution array.
*
* @return bool
* TRUE, if that resolution is portrait, FALSE otherwise.
*/
protected function isPortraitOrientation(array $resolution): bool {
return str_starts_with($resolution['orientation'], self::PORTRAIT_QUERY);
}
/**
* Gets the applet UID prefix.
*
* @return string
* The applet UID prefix.
*/
protected function getAppletUidId(): string {
return 'applet_uid';
}
/**
* Gets the applet UID prefix.
*
* @return string
* The applet UID prefix.
*/
protected function getAppletBinaryHashId(): string {
return 'applet_binary_hash';
}
/**
* Gets the applet name.
*
* @return string
* The applet name-
*/
protected function getAppletName(): string {
return self::APPLET_NAME . ': ' . $this->configFactory->get('system.site')->get('name');
}
/**
* Stores the applet UID into settings.
*
* @param string $appletUid
* The applet UID.
*/
protected function saveAppletUid(string $appletUid): void {
$this->configFactory->getEditable('signageos.settings')
->set($this->getAppletUidId(), $appletUid)
->save();
}
/**
* Stores the hash of the applet binary into settings.
*
* @param string $hash
* The hash.
*/
protected function saveAppletBinaryHash(string $hash): void {
$this->configFactory->getEditable('signageos.settings')
->set($this->getAppletBinaryHashId(), $hash)
->save();
}
/**
* Gets the applet UID.
*
* @param bool $push
* TRUE, if the applet should be pushed.
*
* @return string|null
* The applet UID or NULL, of process failed.
*/
protected function getAppletUid(bool $push = TRUE): ?string {
$appletUid = $this->configFactory->get('signageos.settings')->get($this->getAppletUidId());
if (empty($appletUid)) {
if ($push) {
$appletUid = $this->pushApplet();
}
else {
$applet = $this->findOrCreateApplet();
$appletUid = $applet['uid'];
}
$this->saveAppletUid($appletUid);
}
return $appletUid;
}
/**
* Gets the applet's binary hash.
*
* @param string|null $appletBinary
* If NULL, the stored hash from config will be returned, otherwise the
* hash will be calculated from the given applet binary.
*
* @return string
* The hash.
*/
protected function getAppletBinaryHash(?string $appletBinary = NULL): string {
return $appletBinary === NULL ?
$this->configFactory->get('signageos.settings')->get($this->getAppletBinaryHashId()) ?? '' :
Crypt::hashBase64($appletBinary);
}
/**
* Get the current applet, or create a new one.
*
* @return array|null
* The applet definition.
*/
protected function findOrCreateApplet(): ?array {
foreach ($this->request('applet') as $applet) {
if ($applet['name'] === $this->getAppletName()) {
return $applet;
}
}
$this->request('applet', 'post', ['name' => $this->getAppletName()]);
return $this->findOrCreateApplet();
}
/**
* Push the applet.
*
* @return string
* The applet UID.
*/
public function pushApplet(): string {
$theme = $this->configFactory->get('system.theme')->get('default');
$logo_file = theme_get_setting('logo.url', $theme);
if (pathinfo($logo_file, PATHINFO_EXTENSION) === 'svg') {
$logo_content = file_get_contents(DRUPAL_ROOT . $logo_file);
if ($pos = strpos($logo_content, '<svg')) {
$logo_content = substr($logo_content, $pos);
}
}
else {
$logo_content = '';
}
// Build applet.
$build = [
'#theme' => 'signageos_applet',
'#scriptbase' => Url::fromUserInput('/' . $this->moduleList->getPath('signageos') . '/js/', [
'absolute' => TRUE,
])->toString(),
'#sitename' => $this->configFactory->get('system.site')->get('name'),
'#welcome' => $this->welcomeMsg,
'#bgcolor' => $this->bgColor,
'#fgcolor' => $this->fgColor,
'#logo' => $logo_content,
];
$binary = (string) $this->renderer->renderPlain($build);
$existingHash = $this->getAppletBinaryHash();
$newHash = $this->getAppletBinaryHash($binary);
// Find or create applet and version.
if ($applet_uid = $this->getAppletUid(FALSE)) {
$applet = $this->request('applet/' . $applet_uid);
}
if (empty($applet)) {
$applet = $this->findOrCreateApplet();
}
$appletRelease = $this->request('applet/' . $applet['uid'] . '/version/' . self::APPLET_VERSION);
if (empty($appletRelease)) {
// Create the applet release.
$this->request('applet/' . $applet['uid'] . '/version', 'post', [
'binary' => $binary,
'version' => self::APPLET_VERSION,
'frontAppletVersion' => self::FRONT_APPLET_VERSION,
]);
$appletRelease = $this->request('applet/' . $applet['uid'] . '/version/' . self::APPLET_VERSION);
$this->messenger->addStatus('Created new applet ' . $applet['name']);
}
elseif (
$appletRelease['appletUid'] !== $applet_uid
|| $appletRelease['version'] !== self::APPLET_VERSION
|| $appletRelease['frontAppletVersion'] !== self::FRONT_APPLET_VERSION
|| $existingHash !== $newHash
) {
// Update an applet release.
$this->request('applet/' . $applet['uid'] . '/version/' . self::APPLET_VERSION, 'put', [
'binary' => $binary,
'frontAppletVersion' => self::FRONT_APPLET_VERSION,
]);
$this->messenger->addStatus('Updated applet ' . $applet['name']);
}
if (empty($applet_uid) || $applet_uid !== $appletRelease['appletUid']) {
$this->saveAppletUid($appletRelease['appletUid']);
}
if ($existingHash !== $newHash) {
$this->saveAppletBinaryHash($newHash);
}
return $appletRelease['appletUid'];
}
/**
* Push configuration to a device.
*
* @param \Drupal\digital_signage_framework\DeviceInterface $device
* The device.
* @param string $applet_uid
* The applet UID.
* @param bool $reload_schedule
* Flag, whether the device should reload the schedule.
* @param array $configuration
* The configuration.
*/
protected function sendCommandUpdateConfiguration(DeviceInterface $device, string $applet_uid, bool $reload_schedule, array $configuration): void {
$this->request('device/' . $device->extId() . '/applet/' . $applet_uid . '/command', 'post', [
'commandPayload' => [
'payload' => [
'reload' => $reload_schedule,
'config' => $configuration,
],
'type' => 'UpdateConfig',
],
]);
}
/**
* Get the device logs.
*
* @param \Drupal\digital_signage_framework\DeviceInterface $device
* The device.
* @param string $type
* The log type, either "log" or "error".
*
* @return array
* The log records.
*/
protected function getLog(DeviceInterface $device, string $type): array {
$log = [];
if ($applet_uid = $this->getAppletUid(FALSE)) {
foreach ($this->request('device/' . $device->extId() . '/applet/' . $applet_uid . '/command?type=' . $type . '&receivedSince=' . $this->getSince(300)) as $item) {
$message = $item['commandPayload']['payload'];
if (!is_scalar($message)) {
$message = json_encode($message);
if (strlen($message) > 80) {
try {
$this->storeRecord($item['uid'], $item['commandPayload']['payload']);
}
catch (TempStoreException) {
// @todo Log this exception.
continue;
}
$message = substr($message, 0, 70) . '... ' . $item['uid'];
}
}
$log[] = [
'time' => $item['receivedAt'],
'payload' => $item['commandPayload']['payload'],
'uid' => $item['uid'],
'message' => $message,
];
}
}
return $log;
}
/**
* {@inheritdoc}
*/
public function showDebugLog(DeviceInterface $device): array {
return $this->getLog($device, 'DeviceLog.Debug');
}
/**
* {@inheritdoc}
*/
public function showErrorLog(DeviceInterface $device): array {
return $this->getLog($device, 'DeviceLog.Error');
}
/**
* {@inheritdoc}
*/
public function showSlideReport(DeviceInterface $device): array {
return $this->getLog($device, 'DeviceReport.Slide');
}
/**
* {@inheritdoc}
*/
public function debugDevice(DeviceInterface $device): void {
$this->request('device/' . $device->extId() . '/debug', 'put', [
'appletEnabled' => TRUE,
'nativeEnabled' => TRUE,
]);
}
/**
* Trigger power action on a device.
*
* @param \Drupal\digital_signage_framework\DeviceInterface $device
* The device.
* @param string $action
* The action ID.
*/
public function sendPowerAction(DeviceInterface $device, string $action): void {
$this->request('device/' . $device->extId() . '/power-action', 'post', [
'devicePowerAction' => $action,
]);
}
/**
* {@inheritdoc}
*/
public function getScreenshot(DeviceInterface $device, $refresh = FALSE): array {
if ($refresh) {
$this->request('device/' . $device->extId() . '/screenshot', 'post');
}
if ($screenshots = $this->request('device/' . $device->extId() . '/screenshot?takenSince=' . $this->getSince(600))) {
return array_pop($screenshots);
}
return [];
}
/**
* Helper function to build a query argument to receive logs of a time span.
*
* @param int $seconds
* Seconds from now into the past.
*
* @return string
* Formatted datetime string for sOS requests.
*/
private function getSince(int $seconds): string {
$since = $this->dateFormatter->format($this->time->getCurrentTime() - $seconds, 'custom', DATE_RFC3339_EXTENDED, 'utc');
return str_replace('+00:00', 'Z', $since);
}
}
