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> * @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> */ 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 * @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 $device * @return array|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 $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 $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 $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 $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 $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 $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 $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 $device * @param array|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 $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 */ 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 []; } }