Files
api/services/nginx/app/classes/shelly_relay_inventory.php
T
Jeppe Bundgaard e93d3f30d2 Add support for Shelly device generation detection and update related tests
- 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.
2026-04-27 17:40:39 +02:00

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 [];
}
}