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

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc