RegisterPropertyString('DeviceTopicPrefix', 'shelly'); $this->RegisterPropertyBoolean('Debug', false); $this->ConnectParent(self::MQTT_SERVER_MODULE_ID); } public function ApplyChanges() { parent::ApplyChanges(); } public function ReceiveData($JSONString) { try { $data = json_decode((string) $JSONString, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { $this->debug('Ungültiges MQTT-Datenpaket', $exception->getMessage()); return; } if (!is_array($data) || (int) ($data['PacketType'] ?? 0) !== 3) { return; } $topic = $data['Topic'] ?? null; $payload = $data['Payload'] ?? null; if (!is_string($topic) || !is_string($payload)) { return; } $topicParts = explode('/', $topic); if (count($topicParts) < 2) { return; } $deviceTopic = array_shift($topicParts); if (!$this->isAcceptedDeviceTopic($deviceTopic)) { return; } $this->debug('MQTT empfangen', $topic . ' -> ' . $payload); if ($topicParts === ['online']) { $this->handleOnline($deviceTopic, $payload); return; } if ($topicParts === ['events', 'rpc']) { $this->handleRpc($deviceTopic, $payload); } } public function RequestAction($Ident, $Value) { $parts = explode(':', (string) $Ident, 2); if (count($parts) !== 2 || !ctype_digit($parts[0])) { throw new InvalidArgumentException('Ungültige Shelly-Aktion: ' . (string) $Ident); } $folderID = (int) $parts[0]; $variableIdent = $parts[1]; if (!IPS_ObjectExists($folderID) || IPS_GetParent($folderID) !== $this->InstanceID) { throw new InvalidArgumentException('Der Geräteordner gehört nicht zu dieser Instanz.'); } if (preg_match('/_output_(\d+)$/', $variableIdent, $matches) !== 1) { throw new InvalidArgumentException('Unbekannter Shelly-Ausgang: ' . $variableIdent); } $variableID = @IPS_GetObjectIDByIdent($variableIdent, $folderID); if ($variableID === false || !IPS_VariableExists($variableID)) { throw new InvalidArgumentException('Die Ausgangsvariable wurde nicht gefunden.'); } $this->SetOutput(IPS_GetName($folderID), (int) $matches[1], (bool) $Value); } public function SetOutput(string $DeviceTopic, int $Output, bool $Value): void { if (!$this->isAcceptedDeviceTopic($DeviceTopic)) { throw new InvalidArgumentException('Das Geräte-Topic entspricht nicht dem konfigurierten Präfix.'); } if ($Output < 0) { throw new InvalidArgumentException('Der Ausgangsindex darf nicht negativ sein.'); } $payload = json_encode([ 'id' => random_int(1, 2147483647), 'src' => 'enelix-symcon-' . $this->InstanceID, 'method' => 'Switch.Set', 'params' => [ 'id' => $Output, 'on' => $Value, ], ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $this->publish($DeviceTopic . '/rpc', $payload); } private function handleOnline(string $deviceTopic, string $payload): void { $value = ShellyParser::toBoolean($payload); if ($value === null) { $this->debug('Online-Status ignoriert', $payload); return; } $variableID = $this->ensureVariable($deviceTopic, 'online', 'Online', 0, '', 10); if ($variableID > 0) { SetValue($variableID, $value); } } private function handleRpc(string $deviceTopic, string $payload): void { try { $rpc = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { $this->debug('Ungültiges Shelly-RPC', $exception->getMessage()); return; } if (!is_array($rpc)) { return; } $source = $rpc['src'] ?? ''; if (!is_string($source) || ShellyParser::extractType($source) === 'unknown') { return; } $params = $rpc['params'] ?? null; if (!is_array($params)) { return; } $typeID = $this->ensureVariable($deviceTopic, 'type', 'Typ', 3, '', 20); if ($typeID > 0) { SetValue($typeID, ShellyParser::extractType($source)); } $mapped = ShellyParser::mapParams($params); foreach ($mapped['inputs'] as $index => $value) { $variableID = $this->ensureVariable( $deviceTopic, 'input_' . $index, 'Input ' . $index, 0, '', 100 + $index ); if ($variableID > 0) { SetValue($variableID, $value); } } foreach ($mapped['outputs'] as $index => $value) { $variableID = $this->ensureVariable( $deviceTopic, 'output_' . $index, 'Output ' . $index, 0, '~Switch', 200 + $index, true ); if ($variableID > 0) { SetValue($variableID, $value); } } if ($mapped['temperature'] !== null) { $variableID = $this->ensureVariable( $deviceTopic, 'temperature', 'Temperatur', 2, '~Temperature', 300 ); if ($variableID > 0) { SetValue($variableID, $mapped['temperature']); } } } private function ensureVariable( string $deviceTopic, string $suffix, string $name, int $type, string $profile, int $position, bool $action = false ): int { $folderID = $this->ensureDeviceFolder($deviceTopic); $ident = $this->deviceKey($deviceTopic) . '_' . $suffix; $variableID = @IPS_GetObjectIDByIdent($ident, $folderID); if ($variableID !== false) { if (!IPS_VariableExists($variableID) || IPS_GetVariable($variableID)['VariableType'] !== $type) { $this->debug('Variablentyp-Konflikt', $ident); return 0; } } else { $variableID = IPS_CreateVariable($type); IPS_SetParent($variableID, $folderID); IPS_SetIdent($variableID, $ident); } IPS_SetName($variableID, $name); IPS_SetPosition($variableID, $position); if ($profile !== '') { IPS_SetVariableCustomProfile($variableID, $profile); } if ($action) { $scriptID = $this->ensureActionScript(); if ($scriptID > 0) { IPS_SetVariableCustomAction($variableID, $scriptID); } } return $variableID; } private function ensureDeviceFolder(string $deviceTopic): int { $ident = 'folder_' . $this->deviceKey($deviceTopic); $folderID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); if ($folderID !== false) { return $folderID; } $folderID = IPS_CreateCategory(); IPS_SetParent($folderID, $this->InstanceID); IPS_SetIdent($folderID, $ident); IPS_SetName($folderID, $deviceTopic); IPS_SetInfo($folderID, 'Automatisch aus MQTT-Topic ' . $deviceTopic . ' angelegt.'); return $folderID; } private function ensureActionScript(): int { $ident = 'action_handler'; $scriptID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); if ($scriptID === false) { $scriptID = IPS_CreateScript(0); IPS_SetParent($scriptID, $this->InstanceID); IPS_SetIdent($scriptID, $ident); } elseif (!IPS_ScriptExists($scriptID)) { $this->debug('Action Handler', 'Ident ist bereits durch ein anderes Objekt belegt.'); return 0; } IPS_SetName($scriptID, 'Shelly Action Handler'); IPS_SetHidden($scriptID, true); IPS_SetScriptContent($scriptID, <<<'PHP' InstanceID)['ConnectionID'] ?? 0); if ($parentID === 0 || !IPS_InstanceExists($parentID)) { throw new RuntimeException('Kein MQTT-Gateway verbunden.'); } if ((int) IPS_GetInstance($parentID)['InstanceStatus'] !== 102) { throw new RuntimeException('Das verbundene MQTT-Gateway ist nicht aktiv.'); } $packet = json_encode([ 'DataID' => self::MQTT_TX_DATA_ID, 'PacketType' => 3, 'QualityOfService' => 0, 'Retain' => false, 'Topic' => $topic, 'Payload' => $payload, ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $this->SendDataToParent($packet); $this->debug('MQTT gesendet', $topic . ' -> ' . $payload); } private function isAcceptedDeviceTopic(string $deviceTopic): bool { if ($deviceTopic === '' || strlen($deviceTopic) > 128) { return false; } if (preg_match('//u', $deviceTopic) !== 1) { return false; } if (preg_match('/[\x00-\x1F\x7F\/#\+]/u', $deviceTopic) === 1) { return false; } $prefix = trim($this->ReadPropertyString('DeviceTopicPrefix')); return $prefix === '' || strncasecmp($deviceTopic, $prefix, strlen($prefix)) === 0; } private function deviceKey(string $deviceTopic): string { $normalized = strtolower($deviceTopic); $normalized = preg_replace('/[^a-z0-9_]+/', '_', $normalized) ?? 'device'; $normalized = trim($normalized, '_'); if ($normalized === '') { $normalized = 'device'; } return substr($normalized, 0, 48) . '_' . substr(hash('sha256', $deviceTopic), 0, 8); } private function debug(string $title, $message): void { if (!$this->ReadPropertyBoolean('Debug')) { return; } if (!is_string($message)) { $message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } $this->SendDebug($title, (string) $message, 0); } }