Newer
Older
smart-home-server / server / SHServ / Tools / DeviceAPI / Base.php
<?php

namespace SHServ\Tools\DeviceAPI;

class Base {
	private string $ip_address;
	private string $base_url;
	private ?string $token;

	/**
	 * @param string      $ip_address IP устройства (без протокола и слеша в конце)
	 * @param string|null $token      Токен авторизации (может быть null)
	 */
	public function __construct(string $ip_address, ?string $token = null) {
		$this->ip_address = $ip_address;
		$this->base_url   = rtrim('http://' . $ip_address, '/');
		$this->token      = $token;
	}

	/**
	 * Локально обновить токен (без запроса к устройству).
	 */
	public function set_local_token(string $token): void {
		$this->token = $token;
	}

	/**
	 * Получить текущий сохранённый токен.
	 */
	public function get_local_token(): ?string {
		return $this->token;
	}

	/**
	 * GET /about (доступен всегда, без токена).
	 */
	public function get_about(): array {
		return $this->request('GET', '/about');
	}

	/**
	 * GET /status
	 *
	 * В режиме normal требует токен, в setup может не требоваться.
	 * Токен отправляем, если он есть, но жёстко не требуем.
	 */
	public function get_status(): array {
		return $this->request('GET', '/status');
	}

	/**
	 * POST /action
	 *
	 * В normal-режиме требует токен.
	 *
	 * @param string $action
	 * @param array  $params
	 */
	public function post_action(string $action, array $params = []): array {
		$body = [
			'action' => $action,
			'params' => (object)$params, // чтобы пустой массив ушёл как {}
		];

		return $this->request('POST', '/action', $body, true);
	}

	/**
	 * POST /set_token
	 *
	 * В setup-режиме не требует токен, в normal — требует.
	 * Мы отправляем токен, если он есть, но не делаем жёсткую проверку.
	 * При успешном ответе можно локально обновить токен клиента.
	 */
	public function remote_set_token(string $token): array {
		$response = $this->request('POST', '/set_token', [
			'token' => $token,
		]);

		if (($response['http_code'] ?? 0) === 200 && ($response['data']['status'] ?? null) === 'ok') {
			$this->token = $token;
		}

		return $response;
	}

	/**
	 * POST /reboot
	 *
	 * Требует токен в normal-режиме.
	 */
	public function reboot(): array {
		return $this->request('POST', '/reboot', (object)[], true);
	}

	/**
	 * POST /reset
	 *
	 * Требует токен в normal-режиме.
	 */
	public function reset(): array {
		return $this->request('POST', '/reset', (object)[], true);
	}

	/**
	 * POST /set_device_name
	 *
	 * Требует токен в normal-режиме.
	 */
	public function set_device_name(string $device_name): array {
		return $this->request('POST', '/set_device_name', [
			'device_name' => $device_name,
		], true);
	}

	/**
	 * Базовый HTTP-запрос к устройству.
	 *
	 * @param string     $method        GET|POST
	 * @param string     $path          Например, '/about'
	 * @param array|object|null $body   Тело запроса для POST
	 * @param bool       $require_token Обязателен ли токен для этого вызова
	 *
	 * @return array{
	 *   http_code:int,
	 *   headers:array<string,string>,
	 *   raw:string|null,
	 *   data:array<string,mixed>|null,
	 *   error:string|null
	 * }
	 */
	private function request(string $method, string $path, $body = null, bool $require_token = false): array {
		if ($require_token && $this->token === null) {
			throw new \LogicException('Token is required for this endpoint but not set in DeviceApiClient');
		}

		$url = $this->base_url . $path;

		$ch = curl_init();
		if ($ch === false) {
			throw new \RuntimeException('Failed to initialize cURL');
		}

		$headers = [
			'Accept: application/json',
		];

		if ($this->token !== null) {
			$headers[] = 'Authorization: Bearer ' . $this->token;
		}

		if (strtoupper($method) === 'POST') {
			curl_setopt($ch, CURLOPT_POST, true);

			if ($body !== null) {
				$json_body = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
				if ($json_body === false) {
					curl_close($ch);
					throw new \RuntimeException('Failed to encode JSON body: ' . json_last_error_msg());
				}
				curl_setopt($ch, CURLOPT_POSTFIELDS, $json_body);
				$headers[] = 'Content-Type: application/json';
			}
		} else {
			curl_setopt($ch, CURLOPT_HTTPGET, true);
		}

		curl_setopt_array($ch, [
			CURLOPT_URL            => $url,
			CURLOPT_RETURNTRANSFER => true,
			CURLOPT_HEADER         => true,
			CURLOPT_TIMEOUT        => 5,
			CURLOPT_HTTPHEADER     => $headers,
		]);

		$raw_response = curl_exec($ch);

		if ($raw_response === false) {
			$error_message = curl_error($ch);
			curl_close($ch);

			return [
				'http_code' => 0,
				'headers'   => [],
				'raw'       => null,
				'data'      => null,
				'error'     => $error_message,
			];
		}

		$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
		$http_code   = curl_getinfo($ch, CURLINFO_HTTP_CODE);

		curl_close($ch);

		$raw_headers = substr($raw_response, 0, $header_size);
		$raw_body    = substr($raw_response, $header_size);

		$parsed_headers = $this->parse_headers($raw_headers);

		$decoded = null;
		$error   = null;

		if ($raw_body !== '') {
			$decoded = json_decode($raw_body, true);
			if (!is_array($decoded)) {
				$error = 'Invalid JSON response from device';
				$decoded = null;
			}
		}

		return [
			'http_code' => (int)$http_code,
			'headers'   => $parsed_headers,
			'raw'       => $raw_body,
			'data'      => $decoded,
			'error'     => $error,
		];
	}

	/**
	 * Простейший парсер HTTP-заголовков.
	 *
	 * @param string $raw_headers
	 *
	 * @return array<string,string>
	 */
	private function parse_headers(string $raw_headers): array
	{
		$headers = [];

		$lines = preg_split("/\r\n|\n|\r/", trim($raw_headers));
		if (!is_array($lines)) {
			return $headers;
		}

		foreach ($lines as $line) {
			if (strpos($line, ':') === false) {
				continue;
			}

			[$name, $value] = explode(':', $line, 2);
			$name  = trim($name);
			$value = trim($value);

			if ($name !== '') {
				$headers[$name] = $value;
			}
		}

		return $headers;
	}
}