- Implement generation detection logic for Shelly devices using model codes, metadata, and type inference. - Extend relay switch and inventory handling to include generation capabilities. - Ensure compatibility with Gen1, Gen2, and Gen3 devices for relay control and diagnostics. - Update unit tests to validate generation inference, fallback behavior, and API compatibility.
620 lines
19 KiB
PHP
620 lines
19 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use Exception;
|
|
|
|
class shelly_relay_inventory
|
|
{
|
|
private ?shelly $client;
|
|
/** @var callable|null */
|
|
private $inventory_fetcher = null;
|
|
/** @var callable|null */
|
|
private $device_list_fetcher = null;
|
|
|
|
public function __construct(?shelly $client = null)
|
|
{
|
|
$this->client = $client;
|
|
}
|
|
|
|
public function setInventoryFetcher(callable $fetcher): self
|
|
{
|
|
$this->inventory_fetcher = $fetcher;
|
|
return $this;
|
|
}
|
|
|
|
public function setDeviceListFetcher(callable $fetcher): self
|
|
{
|
|
$this->device_list_fetcher = $fetcher;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array<string,mixed>>
|
|
* @throws Exception
|
|
*/
|
|
public function listRelayOptions(): array
|
|
{
|
|
$devices_status = $this->fetchOwnedDevicesStatus();
|
|
$device_catalog = $this->fetchOwnedDeviceCatalog();
|
|
$options_by_id = [];
|
|
|
|
foreach ($devices_status as $device) {
|
|
$normalized_device = $this->normalizeToArray($device);
|
|
$device_id = $this->extractFirstString([
|
|
$normalized_device['_dev_info']['id'] ?? null,
|
|
$normalized_device['id'] ?? null,
|
|
]);
|
|
$catalog_entry = $device_id !== '' ? ($device_catalog[$device_id] ?? null) : null;
|
|
|
|
$option = $this->buildRelayOption(
|
|
$normalized_device,
|
|
is_array($catalog_entry) ? $catalog_entry : null
|
|
);
|
|
if ($option === null) {
|
|
continue;
|
|
}
|
|
|
|
$options_by_id[$option['id']] = $option;
|
|
}
|
|
|
|
$options = array_values($options_by_id);
|
|
usort($options, static function (array $left, array $right): int {
|
|
$status_compare = self::statusSortWeight((string)($left['status_color'] ?? ''))
|
|
<=> self::statusSortWeight((string)($right['status_color'] ?? ''));
|
|
if ($status_compare !== 0) {
|
|
return $status_compare;
|
|
}
|
|
|
|
$name_compare = strcasecmp((string)($left['name'] ?? ''), (string)($right['name'] ?? ''));
|
|
if ($name_compare !== 0) {
|
|
return $name_compare;
|
|
}
|
|
|
|
return strcmp((string)($left['id'] ?? ''), (string)($right['id'] ?? ''));
|
|
});
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,array<string,mixed>>
|
|
*/
|
|
private function fetchOwnedDeviceCatalog(): array
|
|
{
|
|
try {
|
|
$payload = is_callable($this->device_list_fetcher)
|
|
? ($this->device_list_fetcher)()
|
|
: $this->getClient()->sendGetRequest('/interface/device/list', [
|
|
'no_shared' => 'true',
|
|
]);
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = $this->normalizeToArray($payload);
|
|
if (($normalized['isok'] ?? true) === false) {
|
|
return [];
|
|
}
|
|
|
|
$devices = $normalized['data']['devices'] ?? null;
|
|
if (!is_array($devices)) {
|
|
return [];
|
|
}
|
|
|
|
$catalog_by_id = [];
|
|
foreach ($devices as $device_key => $device) {
|
|
$normalized_device = $this->normalizeToArray($device);
|
|
$device_id = $this->extractFirstString([
|
|
$normalized_device['id'] ?? null,
|
|
is_string($device_key) ? $device_key : null,
|
|
]);
|
|
if ($device_id === '') {
|
|
continue;
|
|
}
|
|
|
|
$catalog_by_id[$device_id] = $normalized_device;
|
|
}
|
|
|
|
return $catalog_by_id;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
* @throws Exception
|
|
*/
|
|
private function fetchOwnedDevicesStatus(): array
|
|
{
|
|
$payload = is_callable($this->inventory_fetcher)
|
|
? ($this->inventory_fetcher)()
|
|
: $this->getClient()->sendGetRequest('/device/all_status', [
|
|
'show_info' => 'true',
|
|
'no_shared' => 'true',
|
|
]);
|
|
|
|
$normalized = $this->normalizeToArray($payload);
|
|
$is_ok = $normalized['isok'] ?? null;
|
|
if ($is_ok === false) {
|
|
throw new Exception('Shelly relay inventory request failed');
|
|
}
|
|
|
|
$devices_status = $normalized['data']['devices_status'] ?? null;
|
|
if (!is_array($devices_status)) {
|
|
throw new Exception('Shelly relay inventory response was missing devices_status');
|
|
}
|
|
|
|
return $devices_status;
|
|
}
|
|
|
|
private function getClient(): shelly
|
|
{
|
|
if ($this->client instanceof shelly) {
|
|
return $this->client;
|
|
}
|
|
|
|
$this->client = new shelly();
|
|
return $this->client;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $device
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array
|
|
{
|
|
if ($device === [] || !$this->isRelayCapableDevice($device)) {
|
|
return null;
|
|
}
|
|
|
|
$device_id = $this->extractFirstString([
|
|
$device['_dev_info']['id'] ?? null,
|
|
$device['id'] ?? null,
|
|
]);
|
|
if ($device_id === '') {
|
|
return null;
|
|
}
|
|
|
|
$cloud_name = $this->extractFirstString([
|
|
$catalog_entry['name'] ?? null,
|
|
]);
|
|
$local_device_name = $this->extractFirstString([
|
|
$device['name'] ?? null,
|
|
$device['_dev_info']['name'] ?? null,
|
|
$device['settings']['name'] ?? null,
|
|
$device['settings']['device']['name'] ?? null,
|
|
$device['status']['name'] ?? null,
|
|
$device['status']['sys']['device']['name'] ?? null,
|
|
]);
|
|
$device_name = $cloud_name !== '' ? $cloud_name : $local_device_name;
|
|
$device_code = $this->extractFirstString([
|
|
$device['_dev_info']['code'] ?? null,
|
|
$device['code'] ?? null,
|
|
]);
|
|
$device_model = $this->extractDeviceModel($device, $device_code, $catalog_entry);
|
|
if ($device_code === '' && $device_model !== null) {
|
|
$device_code = $device_model;
|
|
}
|
|
$device_type = $this->extractDeviceType($device, $device_code, $catalog_entry);
|
|
$device_generation = $this->extractDeviceGeneration($device, $device_code, $device_type, $catalog_entry);
|
|
$control_type = $this->extractControlType($device);
|
|
$control_name = $this->extractControlName($device);
|
|
$online = $this->extractOnlineState($device);
|
|
if ($online === null) {
|
|
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
|
|
}
|
|
$status_color = $this->extractStatusColor($online);
|
|
$local_ip = $this->extractLocalIp($device, $catalog_entry);
|
|
|
|
return [
|
|
'id' => $device_id,
|
|
'name' => $this->buildRelayLabel(
|
|
$device_type,
|
|
$cloud_name,
|
|
$local_device_name,
|
|
$control_name,
|
|
$device_id
|
|
),
|
|
'device_id' => $device_id,
|
|
'device_name' => $device_name !== '' ? $device_name : null,
|
|
'cloud_name' => $cloud_name !== '' ? $cloud_name : null,
|
|
'device_type' => $device_type,
|
|
'code' => $device_code !== '' ? $device_code : null,
|
|
'device_model' => $device_model,
|
|
'device_generation' => $device_generation,
|
|
'control_type' => $control_type,
|
|
'control_name' => $control_name !== '' ? $control_name : null,
|
|
'local_ip' => $local_ip,
|
|
'status_color' => $status_color,
|
|
'online' => $online,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $device
|
|
*/
|
|
private function isRelayCapableDevice(array $device): bool
|
|
{
|
|
if ($this->payloadContainsRelayState($device)) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->payloadContainsRelayState($device['status'] ?? null)) {
|
|
return true;
|
|
}
|
|
|
|
return $this->payloadContainsRelayState($device['settings'] ?? null);
|
|
}
|
|
|
|
private function payloadContainsRelayState(array|object|null $payload): bool
|
|
{
|
|
$normalized = $this->normalizeToArray($payload);
|
|
if ($normalized === []) {
|
|
return false;
|
|
}
|
|
|
|
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
|
|
if (array_key_exists($switch_key, $normalized)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
foreach (['relays', 'switches'] as $collection_key) {
|
|
if (isset($normalized[$collection_key]) && is_array($normalized[$collection_key]) && $normalized[$collection_key] !== []) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $device
|
|
*/
|
|
private function extractOnlineState(array $device): ?bool
|
|
{
|
|
$online = $device['_dev_info']['online'] ?? $device['online'] ?? null;
|
|
if (is_bool($online)) {
|
|
return $online;
|
|
}
|
|
|
|
if (is_numeric($online)) {
|
|
return (int)$online === 1;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function extractStatusColor(?bool $online): string
|
|
{
|
|
if ($online === true) {
|
|
return 'Green';
|
|
}
|
|
|
|
if ($online === false) {
|
|
return 'Red';
|
|
}
|
|
|
|
return 'Yellow';
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $device
|
|
*/
|
|
private function extractDeviceType(array $device, string $device_code, ?array $catalog_entry = null): ?string
|
|
{
|
|
$candidates = [
|
|
$device['_dev_info']['type'] ?? null,
|
|
$device['type'] ?? null,
|
|
$device['settings']['device']['type'] ?? null,
|
|
$device['_dev_info']['model'] ?? null,
|
|
$device['model'] ?? null,
|
|
$device['_dev_info']['app'] ?? null,
|
|
$device['app'] ?? null,
|
|
$catalog_entry['type'] ?? null,
|
|
$device_code,
|
|
];
|
|
|
|
foreach ($candidates as $candidate) {
|
|
$normalized = trim((string)($candidate ?? ''));
|
|
if ($normalized !== '' && !$this->looksLikeShellyModelCode($normalized)) {
|
|
return $normalized;
|
|
}
|
|
}
|
|
|
|
$device_type = $this->extractFirstString($candidates);
|
|
|
|
return $device_type !== '' ? $device_type : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $device
|
|
*/
|
|
private function extractDeviceModel(array $device, string $device_code, ?array $catalog_entry = null): ?string
|
|
{
|
|
$candidates = [
|
|
$device_code,
|
|
$device['_dev_info']['model'] ?? null,
|
|
$device['model'] ?? null,
|
|
$catalog_entry['type'] ?? null,
|
|
$catalog_entry['model'] ?? null,
|
|
$device['_dev_info']['type'] ?? null,
|
|
$device['type'] ?? null,
|
|
];
|
|
|
|
foreach ($candidates as $candidate) {
|
|
$normalized = trim((string)($candidate ?? ''));
|
|
if ($normalized !== '' && $this->looksLikeShellyModelCode($normalized)) {
|
|
return $normalized;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $device
|
|
*/
|
|
private function extractDeviceGeneration(
|
|
array $device,
|
|
string $device_code,
|
|
?string $device_type,
|
|
?array $catalog_entry = null
|
|
): ?int {
|
|
foreach ([
|
|
$device['_dev_info']['gen'] ?? null,
|
|
$device['_dev_info']['generation'] ?? null,
|
|
$device['gen'] ?? null,
|
|
$device['generation'] ?? null,
|
|
$device['settings']['device']['gen'] ?? null,
|
|
$device['settings']['device']['generation'] ?? null,
|
|
$device['status']['sys']['gen'] ?? null,
|
|
$device['status']['sys']['generation'] ?? null,
|
|
$catalog_entry['gen'] ?? null,
|
|
$catalog_entry['generation'] ?? null,
|
|
] as $candidate) {
|
|
$generation = $this->normalizeDeviceGeneration($candidate);
|
|
if ($generation !== null) {
|
|
return $generation;
|
|
}
|
|
}
|
|
|
|
foreach ([
|
|
$device_code,
|
|
$device_type,
|
|
$device['_dev_info']['model'] ?? null,
|
|
$device['_dev_info']['type'] ?? null,
|
|
$device['_dev_info']['app'] ?? null,
|
|
$device['model'] ?? null,
|
|
$device['type'] ?? null,
|
|
$device['app'] ?? null,
|
|
$catalog_entry['type'] ?? null,
|
|
$catalog_entry['model'] ?? null,
|
|
] as $candidate) {
|
|
$generation = $this->inferDeviceGenerationFromString((string)($candidate ?? ''));
|
|
if ($generation !== null) {
|
|
return $generation;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function normalizeDeviceGeneration(mixed $value): ?int
|
|
{
|
|
if (is_int($value)) {
|
|
return $value > 0 ? $value : null;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
$generation = (int)$value;
|
|
return $generation > 0 ? $generation : null;
|
|
}
|
|
|
|
return $this->inferDeviceGenerationFromString((string)($value ?? ''));
|
|
}
|
|
|
|
private function inferDeviceGenerationFromString(string $value): ?int
|
|
{
|
|
$normalized = trim($value);
|
|
if ($normalized === '') {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $matches) === 1) {
|
|
return (int)$matches[1];
|
|
}
|
|
|
|
$upper = strtoupper($normalized);
|
|
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $matches) === 1) {
|
|
return (int)$matches[1];
|
|
}
|
|
|
|
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1
|
|
|| preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
|
|
return 2;
|
|
}
|
|
|
|
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
|
|
return 1;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function looksLikeShellyModelCode(string $value): bool
|
|
{
|
|
$normalized = strtoupper(trim($value));
|
|
if ($normalized === '') {
|
|
return false;
|
|
}
|
|
|
|
return preg_match('/^(?:S[1-9]|SP|SN|SH)[A-Z0-9]+-[A-Z0-9-]+$/', $normalized) === 1;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $device
|
|
*/
|
|
private function extractControlType(array $device): string
|
|
{
|
|
$status = $this->normalizeToArray($device['status'] ?? null);
|
|
$settings = $this->normalizeToArray($device['settings'] ?? null);
|
|
|
|
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
|
|
if (array_key_exists($switch_key, $device) || array_key_exists($switch_key, $status)) {
|
|
return 'Switch';
|
|
}
|
|
}
|
|
|
|
if ((isset($device['switches']) && is_array($device['switches']) && $device['switches'] !== [])
|
|
|| (isset($settings['switches']) && is_array($settings['switches']) && $settings['switches'] !== [])) {
|
|
return 'Switch';
|
|
}
|
|
|
|
if ((isset($device['relays']) && is_array($device['relays']) && $device['relays'] !== [])
|
|
|| (isset($settings['relays']) && is_array($settings['relays']) && $settings['relays'] !== [])) {
|
|
return 'Relay';
|
|
}
|
|
|
|
return 'Device';
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $device
|
|
*/
|
|
private function extractControlName(array $device): string
|
|
{
|
|
$status = $this->normalizeToArray($device['status'] ?? null);
|
|
$settings = $this->normalizeToArray($device['settings'] ?? null);
|
|
|
|
return $this->extractFirstString([
|
|
$status['switch:0']['name'] ?? null,
|
|
$status['switch_0']['name'] ?? null,
|
|
$status['switch0']['name'] ?? null,
|
|
$device['switches'][0]['name'] ?? null,
|
|
$settings['switches'][0]['name'] ?? null,
|
|
$device['relays'][0]['name'] ?? null,
|
|
$settings['relays'][0]['name'] ?? null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $device
|
|
* @param array<string,mixed>|null $catalog_entry
|
|
*/
|
|
private function extractLocalIp(array $device, ?array $catalog_entry = null): ?string
|
|
{
|
|
$candidates = [
|
|
$device['local_ip'] ?? null,
|
|
$device['localIp'] ?? null,
|
|
$device['ip'] ?? null,
|
|
$device['_dev_info']['local_ip'] ?? null,
|
|
$device['_dev_info']['localIp'] ?? null,
|
|
$device['_dev_info']['ip'] ?? null,
|
|
$device['wifi_sta']['ip'] ?? null,
|
|
$device['wifi']['ip'] ?? null,
|
|
$device['eth']['ip'] ?? null,
|
|
$device['status']['wifi_sta']['ip'] ?? null,
|
|
$device['status']['wifi']['ip'] ?? null,
|
|
$device['status']['eth']['ip'] ?? null,
|
|
$device['status']['sta_ip'] ?? null,
|
|
$device['settings']['wifi_sta']['ip'] ?? null,
|
|
$device['settings']['wifi']['ip'] ?? null,
|
|
$device['settings']['eth']['ip'] ?? null,
|
|
$catalog_entry['local_ip'] ?? null,
|
|
$catalog_entry['localIp'] ?? null,
|
|
$catalog_entry['ip'] ?? null,
|
|
];
|
|
|
|
foreach ($candidates as $candidate) {
|
|
$normalized = trim((string)($candidate ?? ''));
|
|
if ($normalized !== '' && filter_var($normalized, FILTER_VALIDATE_IP) !== false) {
|
|
return $normalized;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function buildRelayLabel(
|
|
?string $device_type,
|
|
string $cloud_name,
|
|
string $local_device_name,
|
|
string $control_name,
|
|
string $device_id
|
|
): string {
|
|
if ($cloud_name !== '') {
|
|
$display_name = $cloud_name;
|
|
} elseif ($local_device_name !== '' && $control_name !== '' && strcasecmp($local_device_name, $control_name) !== 0) {
|
|
$display_name = $local_device_name . ' / ' . $control_name;
|
|
} else {
|
|
$display_name = $control_name !== '' ? $control_name : ($local_device_name !== '' ? $local_device_name : $device_id);
|
|
}
|
|
|
|
if ($device_type !== null && $device_type !== '') {
|
|
return $display_name . ' (' . $device_type . ')';
|
|
}
|
|
|
|
return $display_name;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,mixed> $values
|
|
*/
|
|
private function extractFirstString(array $values): string
|
|
{
|
|
foreach ($values as $value) {
|
|
$normalized = trim((string)($value ?? ''));
|
|
if ($normalized !== '') {
|
|
return $normalized;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function normalizeBoolean(mixed $value): ?bool
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return (int)$value === 1;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static function statusSortWeight(string $status_color): int
|
|
{
|
|
return match (strtolower($status_color)) {
|
|
'green' => 0,
|
|
'yellow' => 1,
|
|
'red' => 2,
|
|
default => 3,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function normalizeToArray(array|object|null $payload): array
|
|
{
|
|
if (is_array($payload)) {
|
|
return $payload;
|
|
}
|
|
|
|
if (is_object($payload)) {
|
|
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
|
if (!is_string($encoded)) {
|
|
return [];
|
|
}
|
|
|
|
$decoded = json_decode($encoded, true);
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
}
|