diff --git a/README.md b/README.md index 87bc912..776f272 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,18 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix ## Status -Das Repository befindet sich im Aufbau. Der Verbrauchskostenreport ist als -installierbares IP-Symcon-Modul enthalten; die weiteren Module sind derzeit -als Diskussionsentwürfe dokumentiert. +Das Repository befindet sich im Aufbau. Verbrauchskostenreport und Shelly +Modul sind als installierbare IP-Symcon-Module enthalten; die weiteren Module +sind derzeit als Diskussionsentwürfe dokumentiert. -## Geplante Module +## Module - Verbrauchskostenreport (implementiert) - Virtuelle Batterie - CC100 Hardware - Energiediagramm - VGT-Schnittstelle -- Shelly Modul +- Shelly Modul (implementiert) Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen Punkten stehen in der [Modulübersicht](docs/module/README.md). diff --git a/ShellyModul/README.md b/ShellyModul/README.md new file mode 100644 index 0000000..9b4d14e --- /dev/null +++ b/ShellyModul/README.md @@ -0,0 +1,107 @@ +# Shelly Modul + +Das Modul bindet Shelly-Geräte ab Generation 2 über deren MQTT/RPC-Protokoll +in IP-Symcon ein. Es erkennt Geräte und Datenpunkte aus Statusmeldungen +automatisch und benötigt keine Abhängigkeit zum Enelix EMS. + +## Voraussetzungen + +- IP-Symcon ab Version 8.0 +- ein eingerichteter IP-Symcon MQTT Server oder MQTT Client +- ein Shelly Gen2+-Gerät mit aktivierter MQTT-Verbindung +- MQTT-RPC-Ereignisse auf `/events/rpc` und der Online-Status auf + `/online` + +Shelly-Geräte der ersten Generation mit `shellies/...`-Topics werden nicht +unterstützt. + +## Einrichtung + +1. Das Modul über die Enelix-Utils-Bibliothek installieren und eine Instanz + `Shelly Modul` anlegen. +2. Das gewünschte MQTT-Gateway verbinden. Neue Instanzen verwenden + standardmässig den internen MQTT Server; für einen externen Broker kann ein + MQTT Client als Gateway gewählt werden. +3. Am Shelly MQTT aktivieren und den Broker, Port und gegebenenfalls + Zugangsdaten eintragen. Zugangsdaten gehören ausschliesslich in die + MQTT-/Shelly-Konfiguration und nicht in dieses Modul. +4. Den Topic-Präfix prüfen und die Instanz übernehmen. +5. Eine Statusänderung am Gerät auslösen. Der Geräteordner und seine Variablen + werden mit der ersten passenden Meldung angelegt. + +## Konfiguration + +| Property | Standard | Beschreibung | +| --- | --- | --- | +| `DeviceTopicPrefix` | `shelly` | Nur erste Topic-Ebenen mit diesem Präfix werden verarbeitet; Vergleich ohne Beachtung der Gross-/Kleinschreibung. Leer erlaubt alle gültigen Topic-Namen. | +| `Debug` | `false` | Schreibt verarbeitete und gesendete MQTT-Nachrichten in den Instanz-Debug. | + +Ein benutzerdefinierter Shelly-`topic_prefix` muss mit +`DeviceTopicPrefix` übereinstimmen. Das Feld ist ein einfacher Präfix und +kein regulärer Ausdruck. + +## Automatisch angelegte Objekte + +Für jede erste MQTT-Topic-Ebene wird ein Geräteordner angelegt. Technische +Idents bestehen nur aus den von IP-Symcon erlaubten Zeichen und enthalten +zusätzlich einen kurzen Hash des originalen Topic-Namens. Damit bleiben auch +Namen mit Bindestrichen stabil und kollisionsarm. + +| Anzeige | Typ / Zugriff | MQTT-Quelle | +| --- | --- | --- | +| Online | Boolean / Anzeige | `/online` | +| Typ | String / Anzeige | `src` einer RPC-Meldung | +| Input n | Boolean / Anzeige | `input:n.state` oder `switch:n.input` | +| Output n | Boolean / bedienbar | `switch:n.output` | +| Temperatur | Float / Anzeige | erster erkannter Celsiuswert in `temperature`/`tC` | + +Neue Inputs und Outputs werden dynamisch ergänzt. Bestehende Objekte werden +nicht automatisch gelöscht, wenn ein Datenpunkt später nicht mehr gemeldet +wird. + +## Ausgänge schalten + +Eine Bedienung von `Output n` veröffentlicht einen Shelly-RPC-Aufruf +`Switch.Set` auf `/rpc`. Der Variablenwert wird nicht optimistisch +gesetzt; er folgt der nächsten Statusmeldung des Geräts. Dadurch zeigt +IP-Symcon keinen erfolgreichen Schaltvorgang an, wenn der Broker oder das +Gerät den Auftrag nicht ausführt. + +Aus Skripten kann derselbe Befehl direkt aufgerufen werden: + +```php +SHELLY_SetOutput($instanceID, 'shellyplus1pm-a1b2c3', 0, true); +``` + +## Unterstützte RPC-Struktur + +Das Modul verarbeitet Publish-Pakete (`PacketType = 3`) mit JSON-Payloads +nach folgendem Muster: + +```json +{ + "src": "shellyplus1pm-a1b2c3", + "method": "NotifyStatus", + "params": { + "switch:0": { + "output": true, + "temperature": {"tC": 42.5} + }, + "input:0": {"state": false} + } +} +``` + +Unvollständige, ungültige oder nicht zum konfigurierten Topic-Präfix passende +Nachrichten werden ignoriert. Bei aktiviertem Debug werden fehlerhafte +JSON-Payloads und ignorierte Online-Werte nachvollziehbar protokolliert. + +## Hinweise zur Adaption + +Aus `Shelly_Parser_MQTT` von Enelix 1 wurden MQTT/RPC-Datenfluss, +Geräteordner, dynamische Inputs/Outputs, Temperatur und `Switch.Set` +fachlich übernommen. Neu implementiert wurden gültige stabile Idents, +tatsächlich schaltbares Debug-Logging, strikte Payload-Prüfung, ein +konfigurierbarer Topic-Präfix sowie verständliche Fehler bei einem inaktiven +MQTT-Gateway. Die pauschale MQTT-`#`-Subscription und dauerhaftes +System-Logging wurden verworfen. diff --git a/ShellyModul/form.json b/ShellyModul/form.json new file mode 100644 index 0000000..e0623df --- /dev/null +++ b/ShellyModul/form.json @@ -0,0 +1,24 @@ +{ + "elements": [ + { + "type": "Label", + "caption": "Verarbeitet MQTT/RPC-Statusmeldungen von Shelly Gen2+ und legt erkannte Geräte und Datenpunkte automatisch an." + }, + { + "type": "ValidationTextBox", + "name": "DeviceTopicPrefix", + "caption": "Erlaubter MQTT-Topic-Präfix" + }, + { + "type": "CheckBox", + "name": "Debug", + "caption": "Debug-Ausgaben aktivieren" + } + ], + "actions": [ + { + "type": "Label", + "caption": "MQTT wird am Shelly-Gerät konfiguriert. Standardmässig wird der interne MQTT Server verwendet; ein vorhandener MQTT Client kann als Gateway gewählt werden." + } + ] +} diff --git a/ShellyModul/libs/ShellyParser.php b/ShellyModul/libs/ShellyParser.php new file mode 100644 index 0000000..dee34c9 --- /dev/null +++ b/ShellyModul/libs/ShellyParser.php @@ -0,0 +1,136 @@ +, inputs: array, temperature: ?float} + */ + public static function mapParams(array $params): array + { + $mapped = [ + 'outputs' => [], + 'inputs' => [], + 'temperature' => null, + ]; + + foreach ($params as $component => $value) { + if (!is_string($component) || !is_array($value)) { + continue; + } + + if (preg_match('/^switch:(\d+)$/', $component, $matches) === 1) { + $index = (int) $matches[1]; + $output = self::toBoolean($value['output'] ?? null); + $input = self::toBoolean($value['input'] ?? null); + + if ($output !== null) { + $mapped['outputs'][$index] = $output; + } + if ($input !== null) { + $mapped['inputs'][$index] = $input; + } + } + + if (preg_match('/^input:(\d+)$/', $component, $matches) === 1) { + $state = self::toBoolean($value['state'] ?? null); + if ($state !== null) { + $mapped['inputs'][(int) $matches[1]] = $state; + } + } + } + + $mapped['temperature'] = self::findTemperature($params); + ksort($mapped['outputs']); + ksort($mapped['inputs']); + + return $mapped; + } + + public static function toBoolean($value): ?bool + { + if (is_bool($value)) { + return $value; + } + + if ($value === 1 || $value === '1') { + return true; + } + if ($value === 0 || $value === '0') { + return false; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['true', 'on', 'online'], true)) { + return true; + } + if (in_array($normalized, ['false', 'off', 'offline'], true)) { + return false; + } + } + + return null; + } + + private static function findTemperature(array $data): ?float + { + foreach ($data as $key => $value) { + if (!is_string($key)) { + continue; + } + + $lowerKey = strtolower($key); + if ($lowerKey === 'temperature') { + $temperature = self::temperatureFromValue($value); + if ($temperature !== null) { + return $temperature; + } + } + + if ($lowerKey === 'tc' && is_numeric($value)) { + return (float) $value; + } + + if (is_array($value)) { + $temperature = self::findTemperature($value); + if ($temperature !== null) { + return $temperature; + } + } + } + + return null; + } + + private static function temperatureFromValue($value): ?float + { + if (is_numeric($value)) { + return (float) $value; + } + if (!is_array($value)) { + return null; + } + + foreach (['tC', 'tc', 't'] as $key) { + if (array_key_exists($key, $value) && is_numeric($value[$key])) { + return (float) $value[$key]; + } + } + + return null; + } +} diff --git a/ShellyModul/module.json b/ShellyModul/module.json new file mode 100644 index 0000000..5473bc9 --- /dev/null +++ b/ShellyModul/module.json @@ -0,0 +1,18 @@ +{ + "id": "{21D82EDB-AC8E-4F3D-9FEE-D20BCAFC1C33}", + "name": "Shelly Modul", + "type": 3, + "vendor": "Belevo AG", + "aliases": [ + "Shelly MQTT Parser" + ], + "parentRequirements": [ + "{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}" + ], + "childRequirements": [], + "implemented": [ + "{7F7632D9-FA40-4F38-8DEA-C83CD4325A32}" + ], + "prefix": "SHELLY", + "url": "" +} diff --git a/ShellyModul/module.php b/ShellyModul/module.php new file mode 100644 index 0000000..0c55605 --- /dev/null +++ b/ShellyModul/module.php @@ -0,0 +1,350 @@ +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); + } +} diff --git a/docs/module/README.md b/docs/module/README.md index c84160b..dea99f4 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,7 +1,7 @@ # Modulübersicht Enelix Utils -> Status: Der Verbrauchskostenreport ist implementiert. Die übrigen Module -> sind als Diskussionsentwürfe dokumentiert. +> Status: Verbrauchskostenreport und Shelly Modul sind implementiert. Die +> übrigen Module sind als Diskussionsentwürfe dokumentiert. | Modul | Herkunft | EMS-Abhängigkeit | | --- | --- | --- | diff --git a/docs/module/Shelly-Modul/README.md b/docs/module/Shelly-Modul/README.md index 23bcc35..ab71c69 100644 --- a/docs/module/Shelly-Modul/README.md +++ b/docs/module/Shelly-Modul/README.md @@ -1,31 +1,98 @@ # Shelly Modul -> Status: Diskussionsentwurf. Übernimmt `Shelly_Parser_MQTT` möglichst -> unverändert und ohne EMS-Abhängigkeit. +> Status: Implementiert. Die fachliche Funktion von +> `Shelly_Parser_MQTT` wurde für IP-Symcon 8.0 bereinigt und ohne +> EMS-Abhängigkeit in Enelix Utils adaptiert. -Das Modul lauscht auf MQTT und legt pro erkanntem Gerät weiterhin einen Ordner -mit Online-Status, Typ, Inputs, Outputs und Temperatur an. +Das Modul verarbeitet Shelly Gen2+-Statusmeldungen über das native +IP-Symcon-MQTT-Datenflussinterface. Ein interner MQTT Server wird beim +Erstellen als Standard-Parent angeboten; ein kompatibler MQTT Client kann +manuell als Gateway verbunden werden. + +## Ziel und Abgrenzung + +- automatische Erkennung von Geräten und vorhandenen Schaltkanälen +- Anzeige von Online-Status, Typ, Inputs, Outputs und Temperatur +- Schalten erkannter Outputs über Shelly RPC `Switch.Set` +- keine Geräteauswahl, Discovery-Instanz oder EMS-Kopplung +- keine Unterstützung für Shelly Gen1-`shellies/...`-Topics +- keine Verwaltung von MQTT-Zugangsdaten oder Shelly-Gerätekonfiguration + +## MQTT-Datenfluss + +| Richtung | Topic | Inhalt | +| --- | --- | --- | +| Shelly nach Symcon | `/online` | `true`/`false`, `1`/`0` oder `online`/`offline` | +| Shelly nach Symcon | `/events/rpc` | RPC-Payload mit `src` und `params` | +| Symcon nach Shelly | `/rpc` | RPC-Aufruf `Switch.Set` | + +Nur MQTT-Publish-Pakete mit `PacketType = 3` werden ausgewertet. Die erste +Topic-Ebene wird als Geräte-Topic verwendet. Der konfigurierte Präfix wird +ohne Beachtung der Gross-/Kleinschreibung geprüft. ## Dynamische Variablen +`` ist ein aus dem Geräte-Topic abgeleiteter, für IP-Symcon +gültiger Ident-Bestandteil mit kurzem Hash. + | Ident-Muster | Typ / Zugriff | Beschreibung | | --- | --- | --- | -| `_online` | Boolean / Anzeige | Online-Meldung. | -| `_type` | String / Anzeige | Erkannter Gerätetyp. | -| `_input_n` | Boolean / Anzeige | Dynamisch erkannter Eingang. | -| `_output_n` | Boolean / bedienbar | Ausgang über vorhandenen Action Handler. | -| `_temperature` | Float / Anzeige | Erkannte Temperatur. | +| `_online` | Boolean / Anzeige | Online-Meldung. | +| `_type` | String / Anzeige | Aus der RPC-`src` extrahierter Modellteil. | +| `_input_n` | Boolean / Anzeige | `input:n.state` oder `switch:n.input`. | +| `_output_n` | Boolean / bedienbar | `switch:n.output`; schaltet per RPC. | +| `_temperature` | Float / Anzeige | Erster eindeutig erkannter Celsiuswert. | + +Die Output-Aktion wartet auf die Rückmeldung des Geräts und setzt den +Variablenwert nicht vorab. Dynamisch angelegte Datenpunkte werden bei später +fehlenden Meldungen nicht gelöscht. ## Properties | Ident | Typ | Standard / Beschreibung | | --- | --- | --- | -| `Debug` | Boolean | `false`; bestehendes Debug-Logging. | +| `DeviceTopicPrefix` | String | `shelly`; einfacher Präfix für die erste Topic-Ebene, leer akzeptiert alle gültigen Namen. | +| `Debug` | Boolean | `false`; Instanz-Debug für empfangene/gesendete MQTT-Daten und Validierungsfehler. | -## Verhalten und offene Punkte +## Öffentliche Funktion -- MQTT-Abonnements, Geräteordner, technische Idents und der interne Action - Handler bleiben erhalten. -- In diesem Schritt keine Geräte- oder Datenpunktauswahl und keine neue - Objektstruktur einführen. -- Beim Praxistest klären, ob der bestehende Debug-Schalter ausreicht. +```php +SHELLY_SetOutput( + int $InstanzID, + string $DeviceTopic, + int $Output, + bool $Value +): void; +``` + +Die Funktion validiert Topic-Präfix und Ausgangsindex. Ein fehlendes oder +inaktives MQTT-Gateway führt zu einer verständlichen Exception. + +## Adaption aus Enelix 1 + +| Teil | Entscheidung | +| --- | --- | +| MQTT-Datenfluss-GUIDs und RPC-`Switch.Set` | angepasst übernommen | +| dynamische Geräteordner und Datenpunkte | angepasst übernommen | +| Parser für Inputs, Outputs und Temperatur | neu und strenger implementiert | +| technische Idents mit roher Shelly-ID | verworfen; Bindestriche sind in IP-Symcon-Idents ungültig | +| gemeinsames Action-Skript | angepasst übernommen und verborgen | +| globale `#`-Subscription | verworfen; das native MQTT-Gateway liefert Publish-Daten über den Datenfluss | +| bedingungsloses `IPS_LogMessage` | verworfen; Debug-Ausgabe respektiert die Property | +| optimistisches Setzen eines Outputs | verworfen; Status folgt der Gerätebestätigung | + +## Betrieb und Diagnose + +1. MQTT-Gateway muss verbunden und aktiv sein. +2. Shelly MQTT muss RPC-Statusmeldungen publizieren. +3. Geräte-Topic und `DeviceTopicPrefix` müssen zusammenpassen. +4. Bei fehlenden Variablen kurzzeitig `Debug` aktivieren und eine + Statusänderung auslösen. +5. Zugangsdaten niemals in Logs oder Repository-Dokumentation übernehmen. + +## Offene Punkte + +- Praxistest mit den im Projekt eingesetzten Shelly-Modellen und deren + Firmwareständen. +- Bei Bedarf spätere Erweiterung um weitere Komponenten wie Cover, Light, + Meter oder Batteriestatus als separat beschlossene Funktionalität. diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index 7cb0f82..d7dde0d 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -17,7 +17,7 @@ final class DokumentationsstrukturTest extends TestCase 'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'], 'Energiediagramm' => ['Energiediagramm', 'Status: Diskussionsentwurf'], 'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'], - 'Shelly-Modul' => ['Shelly-Modul', 'Status: Diskussionsentwurf'], + 'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'], ]; }