diff --git a/Shelly_Parser_MQTT/README.md b/Shelly_Parser_MQTT/README.md new file mode 100644 index 0000000..e69de29 diff --git a/Shelly_Parser_MQTT/form.json b/Shelly_Parser_MQTT/form.json new file mode 100644 index 0000000..5c74efe --- /dev/null +++ b/Shelly_Parser_MQTT/form.json @@ -0,0 +1,14 @@ +{ + "elements": [ + { + "type": "Label", + "caption": "Dieses Modul lauscht auf MQTT-Nachrichten und erstellt pro Shelly-Gerät Variablen (input, output, temperature, online, type)." + }, + { + "type": "CheckBox", + "name": "Debug", + "caption": "Debug-Log aktivieren" + } + ], + "actions": [] +} diff --git a/Shelly_Parser_MQTT/libs/ShellyParser.php b/Shelly_Parser_MQTT/libs/ShellyParser.php new file mode 100644 index 0000000..955f582 --- /dev/null +++ b/Shelly_Parser_MQTT/libs/ShellyParser.php @@ -0,0 +1,116 @@ + "1g4" + * "shellyplusplugs-xyz" => "plusplugs" + */ + public static function ExtractType(string $src): string + { + if (!str_starts_with($src, 'shelly')) { + return 'unknown'; + } + + // nach 'shelly' den Rest nehmen + $rest = substr($src, 6); // z.B. "1g4-12345" oder "mini3-abc" + + // alles vor dem ersten "-" ist der Typ + $parts = explode('-', $rest); + + return $parts[0] ?? 'unknown'; + } + + /** + * Geht rekursiv durch params und sammelt bekannte Werte: + * - input (bool) + * - output (bool) + * - temperature (float, inkl. tC) + */ + public static function MapParams(array $params): array +{ + $mapped = [ + 'outputs' => [], // switch:x → output + 'inputs' => [], // input:x → state oder switch:x.input + 'temperature' => null + ]; + + foreach ($params as $key => $value) { + + // OUTPUTS (switch:0.output) + if (str_starts_with($key, 'switch:') && is_array($value)) { + + $index = (int)substr($key, 7); + + if (isset($value['output'])) { + $mapped['outputs'][$index] = (bool)$value['output']; + } + + // Gen4 / Pro input in switch + if (isset($value['input'])) { + $mapped['inputs'][$index] = (bool)$value['input']; + } + } + + // INPUTS (input:0.state) + if (str_starts_with($key, 'input:') && is_array($value)) { + + $index = (int)substr($key, 6); + + if (isset($value['state'])) { + $mapped['inputs'][$index] = (bool)$value['state']; + } + } + } + + // Temperatur tief suchen + self::ExtractRecursive($params, $mapped); + + return $mapped; +} + + + + + private static function ExtractRecursive(array $data, array &$mapped): void + { + foreach ($data as $key => $value) { + $lowerKey = strtolower((string)$key); + + if (is_array($value)) { + // Temperatur in verschachtelter Struktur, z.B. ["temperature" => ["tC" => 41.2]] + if ($lowerKey === 'temperature') { + if (isset($value['tC']) && is_numeric($value['tC'])) { + $mapped['temperature'] = (float)$value['tC']; + } elseif (isset($value['t']) && is_numeric($value['t'])) { + $mapped['temperature'] = (float)$value['t']; + } + } + self::ExtractRecursive($value, $mapped); + continue; + } + + switch ($lowerKey) { + case 'input': + $mapped['input'] = (bool)$value; + break; + + case 'output': + $mapped['output'] = (bool)$value; + break; + + case 'temperature': + case 'tc': + case 't': + if (is_numeric($value)) { + $mapped['temperature'] = (float)$value; + } + break; + } + } + } +} +?> diff --git a/Shelly_Parser_MQTT/module.json b/Shelly_Parser_MQTT/module.json new file mode 100644 index 0000000..2da3d44 --- /dev/null +++ b/Shelly_Parser_MQTT/module.json @@ -0,0 +1,18 @@ +{ + "id": "{CC65BDF8-3544-6B0D-448E-D95023EDA7D2}", + "name": "Shelly_Parser_MQTT", + "type": 3, + "vendor": "Belevo AG", + "aliases": [ + "Shelly MQTT Parser" + ], + "prefix": "Shelly_Parser_MQTT", + "parentRequirements": [ + "{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}" + ], + "childRequirements": [], + "implemented": [ + "{7F7632D9-FA40-4F38-8DEA-C83CD4325A32}" + ], + "version": "1.0" +} diff --git a/Shelly_Parser_MQTT/module.php b/Shelly_Parser_MQTT/module.php new file mode 100644 index 0000000..5f3fbb5 --- /dev/null +++ b/Shelly_Parser_MQTT/module.php @@ -0,0 +1,338 @@ +ConnectParent('{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}'); + } + + public function ApplyChanges() + { + parent::ApplyChanges(); + + $this->ConnectParent('{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}'); + + // Eines für ALLE Outputs + $this->EnsureActionScript(); + + $this->Subscribed = false; + } + + /* --------------------------------------------------------- + * DEBUG + * ---------------------------------------------------------*/ + private function Log($title, $msg) + { + $this->SendDebug($title, $msg, 0); + IPS_LogMessage("ShellyMQTT/$title", $msg); + } + + /* --------------------------------------------------------- + * SAFE SEND (Parent prüfen) + * ---------------------------------------------------------*/ + private function SafeSend(array $packet) + { + $parent = @IPS_GetInstance($this->InstanceID)['ConnectionID'] ?? 0; + + if ($parent === 0 || !IPS_InstanceExists($parent)) return; + if (IPS_GetInstance($parent)['InstanceStatus'] !== 102) return; + + @$this->SendDataToParent(json_encode($packet)); + } + + /* --------------------------------------------------------- + * SUBSCRIBE + * ---------------------------------------------------------*/ + private function EnsureSubscribe() + { + if ($this->Subscribed) return; + $this->Subscribed = true; + + $this->SafeSend([ + 'DataID' => '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}', + 'PacketType' => 8, + 'QualityOfService' => 0, + 'Retain' => false, + 'Topic' => '#', + 'Payload' => '' + ]); + + $this->Log("Subscribe", "#"); + } + + /* --------------------------------------------------------- + * PUBLISH + * ---------------------------------------------------------*/ + private function Publish(string $topic, string $payload) + { + $this->SafeSend([ + 'DataID' => '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}', + 'PacketType' => 3, + 'QualityOfService' => 0, + 'Retain' => false, + 'Topic' => $topic, + 'Payload' => $payload + ]); + + $this->Log("Publish", "$topic → $payload"); + } + + /* --------------------------------------------------------- + * REQUEST ACTION + * ---------------------------------------------------------*/ + public function RequestAction($Ident, $Value) + { + $this->Log("RequestAction", "$Ident → " . json_encode($Value)); + + if (!str_contains($Ident, '_output_')) + throw new Exception("Unknown Ident: $Ident"); + + // Variable setzen + $varID = $this->FindVariableByIdent($Ident); + if ($varID) SetValue($varID, $Value); + + // deviceID / index extrahieren + [$deviceID, $suffix] = explode('_output_', $Ident); + $index = intval($suffix); + + $topic = "$deviceID/rpc"; + + $payload = json_encode([ + 'id' => 1, + 'src' => 'ips', + 'method' => 'Switch.Set', + 'params' => [ + 'id' => $index, + 'on' => (bool)$Value + ] + ]); + + $this->Publish($topic, $payload); + } + + /* --------------------------------------------------------- + * RECEIVE DATA + * ---------------------------------------------------------*/ + public function ReceiveData($JSONString) + { + $this->EnsureSubscribe(); + + $data = json_decode($JSONString, true); + if (!is_array($data)) return; + + $topic = $data['Topic'] ?? ''; + $payload = $data['Payload'] ?? ''; + + if ($topic === '') return; + + $this->Log("ReceiveTopic", "$topic → $payload"); + + $parts = explode('/', $topic); + $deviceID = $parts[0] ?? ''; + + if ($deviceID === '') return; + + // ONLINE + if (($parts[1] ?? '') === 'online') { + $this->HandleOnline($deviceID, $payload); + return; + } + + // RPC + if (($parts[1] ?? '') === 'events' && ($parts[2] ?? '') === 'rpc') { + $this->HandleRPC($deviceID, $payload); + return; + } + } + + /* --------------------------------------------------------- + * ONLINE + * ---------------------------------------------------------*/ + private function HandleOnline(string $deviceID, string $payload) + { + $value = ($payload === 'true' || $payload === '1'); + $vid = $this->EnsureBooleanVariable($deviceID, $deviceID . '_online', 'Online'); + SetValue($vid, $value); + } + + /* --------------------------------------------------------- + * RPC / EVENTS + * ---------------------------------------------------------*/ + private function HandleRPC(string $deviceID, string $payload) + { + $json = json_decode($payload, true); + if (!is_array($json)) return; + + $src = $json['src'] ?? ''; + if (!str_starts_with($src, 'shelly')) return; + + $type = ShellyParser::ExtractType($src); + $tid = $this->EnsureStringVariable($deviceID, "{$deviceID}_type", "Typ"); + SetValue($tid, $type); + + $params = $json['params'] ?? []; + $mapped = ShellyParser::MapParams($params); + + foreach ($mapped['outputs'] as $i => $val) { + $vid = $this->EnsureBooleanVariable($deviceID, "{$deviceID}_output_$i", "Output $i"); + SetValue($vid, $val); + } + + foreach ($mapped['inputs'] as $i => $val) { + $vid = $this->EnsureBooleanVariable($deviceID, "{$deviceID}_input_$i", "Input $i"); + SetValue($vid, $val); + } + + if ($mapped['temperature'] !== null) { + $f = $this->EnsureFloatVariable($deviceID, "{$deviceID}_temperature", "Temperatur"); + SetValue($f, $mapped['temperature']); + } + } + + /* --------------------------------------------------------- + * ACTION SCRIPT (einmalig für ALLE Outputs) + * ---------------------------------------------------------*/ + private function EnsureActionScript() + { + $ident = "action_handler"; + + $sid = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + if ($sid === false) { + + $sid = IPS_CreateScript(0); + IPS_SetParent($sid, $this->InstanceID); + IPS_SetIdent($sid, $ident); + IPS_SetName($sid, "Shelly Action Handler"); + + $code = <<<'EOF' + + EOF; + + IPS_SetScriptContent($sid, $code); + } + + return $sid; + } + + + /* --------------------------------------------------------- + * VARIABLEN + * ---------------------------------------------------------*/ + private function EnsureBooleanVariable(string $deviceID, string $ident, string $name): int + { + $folder = $this->GetDeviceFolder($deviceID); + $vid = 0; + + foreach (IPS_GetChildrenIDs($folder) as $cid) { + $obj = IPS_GetObject($cid); + + // AutoFix alter Versionen ohne Ident + if ($obj['ObjectName'] === $name && $obj['ObjectIdent'] === '') { + IPS_SetIdent($cid, $ident); + $vid = $cid; + break; + } + + if ($obj['ObjectIdent'] === $ident) { + $vid = $cid; + break; + } + } + + if ($vid === 0) { + $vid = IPS_CreateVariable(0); + IPS_SetName($vid, $name); + IPS_SetIdent($vid, $ident); + IPS_SetParent($vid, $folder); + } + + // OUTPUT → EIN gemeinsames Action Script + if (str_contains($ident, '_output_')) { + IPS_SetVariableCustomProfile($vid, '~Switch'); + IPS_SetVariableCustomAction($vid, $this->EnsureActionScript()); + } + + return $vid; + } + + private function EnsureFloatVariable(string $deviceID, string $ident, string $name): int + { + $folder = $this->GetDeviceFolder($deviceID); + + foreach (IPS_GetChildrenIDs($folder) as $cid) { + if (IPS_GetObject($cid)['ObjectIdent'] === $ident) + return $cid; + } + + $vid = $this->RegisterVariableFloat($ident, $name); + IPS_SetParent($vid, $folder); + return $vid; + } + + private function EnsureStringVariable(string $deviceID, string $ident, string $name): int + { + $folder = $this->GetDeviceFolder($deviceID); + + foreach (IPS_GetChildrenIDs($folder) as $cid) { + if (IPS_GetObject($cid)['ObjectIdent'] === $ident) + return $cid; + } + + $vid = $this->RegisterVariableString($ident, $name); + IPS_SetParent($vid, $folder); + return $vid; + } + + /* --------------------------------------------------------- + * DEVICE FOLDER + * ---------------------------------------------------------*/ + private function GetDeviceFolder(string $deviceID): int + { + $ident = "folder_" . $deviceID; + + foreach (IPS_GetChildrenIDs($this->InstanceID) as $cid) { + if (IPS_GetObject($cid)['ObjectIdent'] === $ident) + return $cid; + } + + $fid = IPS_CreateCategory(); + IPS_SetName($fid, $deviceID); + IPS_SetIdent($fid, $ident); + IPS_SetParent($fid, $this->InstanceID); + + return $fid; + } + + private function FindVariableByIdent(string $Ident): int + { + foreach (IPS_GetChildrenIDs($this->InstanceID) as $folder) { + foreach (IPS_GetChildrenIDs($folder) as $cid) { + if (IPS_GetObject($cid)['ObjectIdent'] === $Ident) + return $cid; + } + } + return 0; + } +} + +?>