'CreateDeviceInfo', 'inputs' => 'CreateInputs', 'switches' => 'CreateSwitches', 'covers' => 'CreateCovers', 'lights' => 'CreateLights', 'power' => 'CreatePower', 'voltage' => 'CreateVoltage', 'current' => 'CreateCurrent', 'energy' => 'CreateEnergy', 'frequency' => 'CreateFrequency', 'power_factor' => 'CreatePowerFactor', 'meters' => 'CreateMeterDetails', 'temperature' => 'CreateTemperature', 'humidity' => 'CreateHumidity', 'illuminance' => 'CreateIlluminance', 'battery' => 'CreateBattery', 'environment' => 'CreateEnvironment', 'system' => 'CreateSystem', 'other' => 'CreateOther', ]; public function Create() { parent::Create(); $this->RegisterPropertyBoolean('UseDeviceTopicFilter', false); $this->RegisterPropertyString('DeviceTopicPrefix', 'shelly'); $this->RegisterPropertyBoolean('CreateOnline', true); $this->RegisterPropertyBoolean('CreateDeviceInfo', true); $this->RegisterPropertyBoolean('CreateInputs', true); $this->RegisterPropertyBoolean('CreateSwitches', true); $this->RegisterPropertyBoolean('CreateCovers', true); $this->RegisterPropertyBoolean('CreateLights', true); $this->RegisterPropertyBoolean('CreatePower', true); $this->RegisterPropertyBoolean('CreateVoltage', true); $this->RegisterPropertyBoolean('CreateCurrent', true); $this->RegisterPropertyBoolean('CreateEnergy', true); $this->RegisterPropertyBoolean('CreateFrequency', true); $this->RegisterPropertyBoolean('CreatePowerFactor', true); $this->RegisterPropertyBoolean('CreateMeterDetails', true); $this->RegisterPropertyBoolean('CreateTemperature', true); $this->RegisterPropertyBoolean('CreateHumidity', true); $this->RegisterPropertyBoolean('CreateIlluminance', true); $this->RegisterPropertyBoolean('CreateBattery', true); $this->RegisterPropertyBoolean('CreateEnvironment', true); $this->RegisterPropertyBoolean('CreateSystem', false); $this->RegisterPropertyBoolean('CreateOther', false); $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; } $topicInfo = ShellyParser::parseTopic($topic); if ($topicInfo === null || !$this->isAcceptedDeviceTopic($topicInfo['deviceTopic'])) { return; } $this->debug('MQTT empfangen', $topic . ' -> ' . $payload); $deviceTopic = $topicInfo['deviceTopic']; if ($topicInfo['kind'] === 'online') { $this->handleOnline($deviceTopic, $payload); return; } $decoded = $this->decodePayload($payload); if ($decoded === null) { return; } if ($topicInfo['kind'] === 'rpc') { $this->handleRpc($deviceTopic, $decoded); } elseif ($topicInfo['kind'] === 'component_status' && $topicInfo['component'] !== null) { $this->updatePoints($deviceTopic, ShellyParser::pointsFromComponent($topicInfo['component'], $decoded)); } elseif ($topicInfo['kind'] === 'status') { $this->updatePoints($deviceTopic, ShellyParser::pointsFromStatus($decoded)); } elseif ($topicInfo['kind'] === 'announce') { $this->updatePoints($deviceTopic, ShellyParser::pointsFromAnnounce($decoded)); } } 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 ist ungültig oder entspricht nicht dem aktiven Filter.'); } 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 decodePayload(string $payload): ?array { try { $decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { $this->debug('Ungültige Shelly-Nutzdaten', $exception->getMessage()); return null; } return is_array($decoded) ? $decoded : null; } private function handleOnline(string $deviceTopic, string $payload): void { if (!$this->ReadPropertyBoolean('CreateOnline')) { return; } $value = $this->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, array $rpc): void { $source = $rpc['src'] ?? ''; if ($this->ReadPropertyBoolean('CreateDeviceInfo') && is_string($source)) { $type = ShellyParser::extractType($source); if ($type !== 'unknown') { $typeID = $this->ensureVariable($deviceTopic, 'type', 'Typ', 3, '', 20); if ($typeID > 0) { SetValue($typeID, $type); } } } $this->updatePoints($deviceTopic, ShellyParser::pointsFromRpc($rpc)); } /** * @param array $points */ private function updatePoints(string $deviceTopic, array $points): void { foreach ($points as $point) { $group = ShellyParser::groupForPoint($point['component'], $point['path']); if (!$this->isGroupEnabled($group)) { continue; } $definition = $this->pointDefinition($deviceTopic, $point); $variableID = $this->ensureVariable( $deviceTopic, $definition['suffix'], $definition['name'], $definition['type'], $definition['profile'], $definition['position'], $definition['action'] ); if ($variableID > 0) { $value = $point['value']; if ($definition['type'] === 1 && is_float($value)) { $value = (int) $value; } elseif ($definition['type'] === 2 && is_int($value)) { $value = (float) $value; } SetValue($variableID, $value); } } } private function isGroupEnabled(string $group): bool { $property = self::GROUP_PROPERTIES[$group] ?? 'CreateOther'; return $this->ReadPropertyBoolean($property); } /** * @param array{component: string, path: string, value: bool|int|float|string} $point * @return array{suffix: string, name: string, type: int, profile: string, position: int, action: bool} */ private function pointDefinition(string $deviceTopic, array $point): array { [$componentType, $componentIndex] = array_pad(explode(':', $point['component'], 2), 2, null); if ($componentType === 'switch' && $point['path'] === 'output' && ctype_digit((string) $componentIndex)) { $index = (int) $componentIndex; return [ 'suffix' => 'output_' . $index, 'name' => 'Output ' . $index, 'type' => 0, 'profile' => '~Switch', 'position' => 200 + $index, 'action' => true, ]; } if (($componentType === 'input' && $point['path'] === 'state') || ($componentType === 'switch' && $point['path'] === 'input')) { $index = ctype_digit((string) $componentIndex) ? (int) $componentIndex : 0; return [ 'suffix' => 'input_' . $index, 'name' => 'Input ' . $index, 'type' => 0, 'profile' => '', 'position' => 100 + $index, 'action' => false, ]; } if ($componentType === 'switch' && str_ends_with(strtolower($point['path']), 'temperature.tc')) { return [ 'suffix' => 'temperature', 'name' => 'Temperatur', 'type' => 2, 'profile' => '~Temperature', 'position' => 300, 'action' => false, ]; } $source = $point['component'] . '.' . $point['path']; $normalized = strtolower($source); $normalized = preg_replace('/[^a-z0-9_]+/', '_', $normalized) ?? 'value'; $normalized = trim($normalized, '_'); $suffix = 'dp_' . substr($normalized, 0, 48) . '_' . substr(hash('sha256', $source), 0, 8); return [ 'suffix' => $suffix, 'name' => $this->displayName($point['component'], $point['path']), 'type' => ShellyParser::variableType($point['value'], $point['path']), 'profile' => $this->profileForPoint($point['component'], $point['path']), 'position' => 1000 + (int) (hexdec(substr(hash('sha256', $source), 0, 4)) % 50000), 'action' => false, ]; } private function displayName(string $component, string $path): string { $fieldNames = [ 'apower' => 'Wirkleistung', 'aprtpower' => 'Scheinleistung', 'voltage' => 'Spannung', 'current' => 'Strom', 'freq' => 'Frequenz', 'pf' => 'Leistungsfaktor', 'total' => 'Gesamt', 'rh' => 'Luftfeuchtigkeit', 'tc' => 'Temperatur', 'current_pos' => 'Position', 'brightness' => 'Helligkeit', ]; $componentLabel = ucfirst(str_replace(':', ' ', $component)); $parts = explode('.', $path); foreach ($parts as &$part) { $lower = strtolower($part); $part = $fieldNames[$lower] ?? ucfirst(str_replace('_', ' ', $part)); } unset($part); return $componentLabel . ' - ' . implode(' / ', $parts); } private function profileForPoint(string $component, string $path): string { $fullPath = strtolower($component . '.' . $path); if (preg_match('/(^|\.)(temperature|tc)(\.|$)/', $fullPath) === 1) { return '~Temperature'; } if (preg_match('/(^|\.)(humidity|rh)(\.|$)/', $fullPath) === 1) { return '~Humidity'; } return ''; } 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_VariableProfileExists($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) > 300 || preg_match('//u', $deviceTopic) !== 1) { return false; } if (preg_match('/[\x00-\x1F\x7F#\+]/u', $deviceTopic) === 1) { return false; } if (!$this->ReadPropertyBoolean('UseDeviceTopicFilter')) { return true; } $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 toBoolean(string $value): ?bool { $normalized = strtolower(trim($value)); if (in_array($normalized, ['true', '1', 'on', 'online'], true)) { return true; } if (in_array($normalized, ['false', '0', 'off', 'offline'], true)) { return false; } return null; } 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); } }