From 1baa63f23ee2f0b3d2767b43743087f9aefae760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=A4fliger?= Date: Thu, 17 Sep 2026 10:19:56 +0000 Subject: [PATCH] Shelly Modul generisch erweitern --- ShellyModul/README.md | 153 +++++++------- ShellyModul/form.json | 37 +++- ShellyModul/libs/ShellyParser.php | 306 ++++++++++++++++++++-------- ShellyModul/module.php | 311 +++++++++++++++++++++-------- docs/module/Shelly-Modul/README.md | 148 ++++++++------ tests/ShellyModulTest.php | 243 ++++++++++++++++++++++ tests/ShellyParserTest.php | 117 +++++++++++ 7 files changed, 1006 insertions(+), 309 deletions(-) create mode 100644 tests/ShellyModulTest.php create mode 100644 tests/ShellyParserTest.php diff --git a/ShellyModul/README.md b/ShellyModul/README.md index 9b4d14e..9ca0b59 100644 --- a/ShellyModul/README.md +++ b/ShellyModul/README.md @@ -1,107 +1,106 @@ # 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. +Das Modul bindet Shelly-NG-Geräte der Generationen 2, 3 und 4 über MQTT/RPC +in IP-Symcon ein. Es erkennt bekannte und zukünftige Komponenten dynamisch +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` +- aktivierte RPC-Statusmeldungen (`rpc_ntf`) +- optional aktivierte vollständige Statusmeldungen (`status_ntf`) -Shelly-Geräte der ersten Generation mit `shellies/...`-Topics werden nicht -unterstützt. +Shelly-Geräte der ersten Generation mit `shellies/...`-Topics verwenden ein +anderes Protokoll und werden von diesem Modul nicht verarbeitet. -## Einrichtung +## Freie MQTT-Namen -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. +MQTT-Client-ID, Brokername, Benutzername und Kennwort werden ausschliesslich +im MQTT-Gateway beziehungsweise im Shelly konfiguriert. Das Modul wertet +diese Werte nicht aus. Der Shelly-`topic_prefix` darf frei gewählt werden und +auch mehrere Ebenen enthalten, zum Beispiel `symcon` oder +`gebaeude/etage/aktor`. -## Konfiguration +Das Modul erkennt die Geräte-Topics anhand ihrer Endung: -| 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. | +| Topic | Verwendung | +| --- | --- | +| `/online` | Online-Status | +| `/events/rpc` | `NotifyStatus` und `NotifyEvent` | +| `/status/` | vollständiger Komponentenstatus | +| `/status` | vollständiger Gerätestatus | +| `/announce` | Geräteinformationen | -Ein benutzerdefinierter Shelly-`topic_prefix` muss mit -`DeviceTopicPrefix` übereinstimmen. Das Feld ist ein einfacher Präfix und -kein regulärer Ausdruck. +Ein optionaler Topic-Filter kann in den Moduleinstellungen aktiviert werden. +Ohne aktivierten Filter werden alle gültigen Shelly-RPC-Topics verarbeitet. -## Automatisch angelegte Objekte +## Datenpunktauswahl -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. +In der Konfiguration kann für jede Gruppe festgelegt werden, ob neue +Variablen angelegt und vorhandene Variablen aktualisiert werden: -| 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` | +- Online-Status und Geräteinformationen +- Eingänge und Eingangsereignisse +- Schaltausgänge +- Rollladen und Beschattung +- Licht, Dimmer und Farbe +- Leistung, Spannung, Strom, Energie, Frequenz und Leistungsfaktor einzeln +- Temperatur, Feuchte, Helligkeit und Batterie einzeln +- weitere Zähler- und Sensorwerte +- System- und Netzwerkdaten +- unbekannte und zukünftige Komponenten -Neue Inputs und Outputs werden dynamisch ergänzt. Bestehende Objekte werden -nicht automatisch gelöscht, wenn ein Datenpunkt später nicht mehr gemeldet -wird. +Systemdaten und unbekannte Datenpunkte sind standardmässig deaktiviert, damit +nicht unerwartet sehr viele Variablen entstehen. Bereits angelegte Variablen +werden beim Abwählen einer Gruppe nicht gelöscht. + +## Dynamische Variablen + +Boolesche Werte werden als Boolean, Zähler und Zeitangaben als Integer, +Messwerte als Float und Texte als String angelegt. Listen wie Fehlercodes oder +Minutenwerte werden vollständig als JSON-String gespeichert. `null` wird +ignoriert, da IP-Symcon keinen Null-Variablentyp besitzt. + +Bekannte Datenpunkte erhalten lesbare Namen und passende Standardprofile, +sofern das Profil in IP-Symcon vorhanden ist. Unbekannte Felder werden über +ihren Komponenten- und Datenpfad stabil und kollisionsarm identifiziert. ## 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: +Erkannte `switch:.output`-Variablen bleiben bedienbar. Eine Bedienung +veröffentlicht `Switch.Set` auf `/rpc`. Der Variablenwert wird erst +mit der nächsten Gerätemeldung aktualisiert. ```php -SHELLY_SetOutput($instanceID, 'shellyplus1pm-a1b2c3', 0, true); +SHELLY_SetOutput($instanceID, 'symcon', 0, true); +SHELLY_SetOutput($instanceID, 'gebaeude/etage/aktor', 1, false); ``` -## Unterstützte RPC-Struktur +Andere Komponenten werden derzeit vollständig eingelesen, aber nicht pauschal +schreibbar gemacht. Ihre RPC-Befehle unterscheiden sich fachlich, etwa bei +Cover-, Light-, RGB- oder Thermostat-Komponenten. -Das Modul verarbeitet Publish-Pakete (`PacketType = 3`) mit JSON-Payloads -nach folgendem Muster: +## Einrichtung -```json -{ - "src": "shellyplus1pm-a1b2c3", - "method": "NotifyStatus", - "params": { - "switch:0": { - "output": true, - "temperature": {"tC": 42.5} - }, - "input:0": {"state": false} - } -} -``` +1. Instanz `Shelly Modul` aus Enelix Utils anlegen. +2. Internen MQTT Server oder kompatiblen MQTT Client als Gateway verbinden. +3. MQTT am Shelly aktivieren und RPC-Statusmeldungen einschalten. +4. Gewünschte Datenpunktgruppen auswählen und Änderungen übernehmen. +5. Am Gerät einen Statuswechsel auslösen. -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. +Bei einem externen MQTT-Broker muss der MQTT Client die passenden Topics +abonnieren. Zugangsdaten gehören niemals in die Modulkonfiguration. -## Hinweise zur Adaption +## Migration -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. +Der bisherige Wert `DeviceTopicPrefix = shelly` bleibt erhalten, wird aber nur +noch ausgewertet, wenn `UseDeviceTopicFilter` aktiviert ist. Bestehende +Online-, Typ-, Input-, Output- und Temperaturvariablen werden weiterverwendet. + +## Diagnose + +Mit `Debug` protokolliert die Instanz empfangene und gesendete MQTT-Pakete +sowie ungültige JSON-Nutzdaten. Für die Fehlersuche sollte Debug nur +vorübergehend aktiviert werden, weil Statusmeldungen umfangreich sein können. diff --git a/ShellyModul/form.json b/ShellyModul/form.json index e0623df..16aa084 100644 --- a/ShellyModul/form.json +++ b/ShellyModul/form.json @@ -2,12 +2,43 @@ "elements": [ { "type": "Label", - "caption": "Verarbeitet MQTT/RPC-Statusmeldungen von Shelly Gen2+ und legt erkannte Geräte und Datenpunkte automatisch an." + "caption": "Verarbeitet Shelly Gen2+-Daten unabhängig von MQTT-Client-ID und frei wählbarem Topic-Präfix." + }, + { + "type": "CheckBox", + "name": "UseDeviceTopicFilter", + "caption": "Nur Geräte-Topics mit folgendem Präfix verarbeiten" }, { "type": "ValidationTextBox", "name": "DeviceTopicPrefix", - "caption": "Erlaubter MQTT-Topic-Präfix" + "caption": "Optionaler Geräte-Topic-Filter" + }, + { + "type": "ExpansionPanel", + "caption": "Zu erzeugende Datenpunktgruppen", + "items": [ + {"type": "CheckBox", "name": "CreateOnline", "caption": "Online-Status"}, + {"type": "CheckBox", "name": "CreateDeviceInfo", "caption": "Geräteinformationen"}, + {"type": "CheckBox", "name": "CreateInputs", "caption": "Eingänge und Eingangsereignisse"}, + {"type": "CheckBox", "name": "CreateSwitches", "caption": "Schaltausgänge"}, + {"type": "CheckBox", "name": "CreateCovers", "caption": "Rollladen und Beschattung"}, + {"type": "CheckBox", "name": "CreateLights", "caption": "Licht, Dimmer und Farbe"}, + {"type": "CheckBox", "name": "CreatePower", "caption": "Leistung"}, + {"type": "CheckBox", "name": "CreateVoltage", "caption": "Spannung"}, + {"type": "CheckBox", "name": "CreateCurrent", "caption": "Strom"}, + {"type": "CheckBox", "name": "CreateEnergy", "caption": "Energiezähler"}, + {"type": "CheckBox", "name": "CreateFrequency", "caption": "Frequenz"}, + {"type": "CheckBox", "name": "CreatePowerFactor", "caption": "Leistungsfaktor"}, + {"type": "CheckBox", "name": "CreateMeterDetails", "caption": "Weitere Zählerdaten"}, + {"type": "CheckBox", "name": "CreateTemperature", "caption": "Temperatur"}, + {"type": "CheckBox", "name": "CreateHumidity", "caption": "Luftfeuchtigkeit"}, + {"type": "CheckBox", "name": "CreateIlluminance", "caption": "Helligkeit und Beleuchtungsstärke"}, + {"type": "CheckBox", "name": "CreateBattery", "caption": "Batterie und Versorgung"}, + {"type": "CheckBox", "name": "CreateEnvironment", "caption": "Weitere Sensorwerte"}, + {"type": "CheckBox", "name": "CreateSystem", "caption": "System- und Netzwerkdaten"}, + {"type": "CheckBox", "name": "CreateOther", "caption": "Unbekannte und zukünftige Datenpunkte"} + ] }, { "type": "CheckBox", @@ -18,7 +49,7 @@ "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." + "caption": "Nicht gewählte Gruppen werden nicht neu angelegt oder aktualisiert. Bereits vorhandene Variablen werden nicht gelöscht." } ] } diff --git a/ShellyModul/libs/ShellyParser.php b/ShellyModul/libs/ShellyParser.php index dee34c9..a09068c 100644 --- a/ShellyModul/libs/ShellyParser.php +++ b/ShellyModul/libs/ShellyParser.php @@ -5,8 +5,38 @@ declare(strict_types=1); final class ShellyParser { /** - * Extrahiert den Modellteil aus einer Shelly-RPC-Source. + * @return null|array{deviceTopic: string, kind: string, component: ?string} */ + public static function parseTopic(string $topic): ?array + { + $topic = trim($topic, '/'); + if ($topic === '') { + return null; + } + + $patterns = [ + 'rpc' => '#^(.+)/events/rpc$#', + 'component_status' => '#^(.+)/status/([^/]+)$#', + 'status' => '#^(.+)/status$#', + 'online' => '#^(.+)/online$#', + 'announce' => '#^(.+)/announce$#', + ]; + + foreach ($patterns as $kind => $pattern) { + if (preg_match($pattern, $topic, $matches) !== 1) { + continue; + } + + return [ + 'deviceTopic' => $matches[1], + 'kind' => $kind, + 'component' => $matches[2] ?? null, + ]; + } + + return null; + } + public static function extractType(string $source): string { if (preg_match('/^shelly([a-z0-9]+)-/i', trim($source), $matches) !== 1) { @@ -17,120 +47,226 @@ final class ShellyParser } /** - * @return array{outputs: array, inputs: array, temperature: ?float} + * @return array */ - public static function mapParams(array $params): array + public static function pointsFromRpc(array $rpc): array { - $mapped = [ - 'outputs' => [], - 'inputs' => [], - 'temperature' => null, - ]; + $method = $rpc['method'] ?? null; + $params = $rpc['params'] ?? null; + if (!is_string($method) || !is_array($params)) { + return []; + } - foreach ($params as $component => $value) { + if ($method === 'NotifyStatus') { + return self::pointsFromStatus($params); + } + if ($method === 'NotifyEvent') { + return self::pointsFromEvents($params); + } + + return []; + } + + /** + * @return array + */ + public static function pointsFromStatus(array $status): array + { + $points = []; + foreach ($status as $component => $value) { + if ($component === 'ts' && (is_int($value) || is_float($value))) { + $points[] = self::point('meta', 'ts', $value); + continue; + } 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; - } - } + self::flatten($component, $value, '', $points); } - $mapped['temperature'] = self::findTemperature($params); - ksort($mapped['outputs']); - ksort($mapped['inputs']); - - return $mapped; + return $points; } - public static function toBoolean($value): ?bool + /** + * @return array + */ + public static function pointsFromComponent(string $component, array $status): array { - if (is_bool($value)) { - return $value; - } + $points = []; + self::flatten($component, $status, '', $points); - 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; + return $points; } - private static function findTemperature(array $data): ?float + /** + * @return array + */ + public static function pointsFromAnnounce(array $announce): array + { + $points = []; + self::flatten('device', $announce, '', $points); + + return $points; + } + + /** + * @return array + */ + private static function pointsFromEvents(array $params): array + { + $points = []; + $events = $params['events'] ?? []; + if (!is_array($events)) { + return $points; + } + + foreach ($events as $event) { + if (!is_array($event)) { + continue; + } + $component = $event['component'] ?? 'event'; + if (!is_string($component) || $component === '') { + $component = 'event'; + } + unset($event['component']); + self::flatten($component, $event, 'event', $points); + } + + return $points; + } + + /** + * @param array $points + */ + private static function flatten(string $component, array $data, string $prefix, array &$points): void { foreach ($data as $key => $value) { - if (!is_string($key)) { + $key = (string) $key; + $path = $prefix === '' ? $key : $prefix . '.' . $key; + if ($value === null) { 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; + if (self::isList($value)) { + $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json !== false) { + $points[] = self::point($component, $path, $json); + } + } else { + self::flatten($component, $value, $path, $points); } + continue; + } + + if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { + $points[] = self::point($component, $path, $value); } } - - return null; } - private static function temperatureFromValue($value): ?float + /** + * @param bool|int|float|string $value + * @return array{component: string, path: string, value: bool|int|float|string} + */ + private static function point(string $component, string $path, $value): array { - if (is_numeric($value)) { - return (float) $value; - } - if (!is_array($value)) { - return null; - } + return ['component' => $component, 'path' => $path, 'value' => $value]; + } - foreach (['tC', 'tc', 't'] as $key) { - if (array_key_exists($key, $value) && is_numeric($value[$key])) { - return (float) $value[$key]; + private static function isList(array $value): bool + { + $expectedKey = 0; + foreach ($value as $key => $_) { + if ($key !== $expectedKey) { + return false; } + ++$expectedKey; } - return null; + return true; + } + + public static function groupForPoint(string $component, string $path): string + { + $componentType = strtolower(explode(':', $component, 2)[0]); + $path = strtolower($path); + + if (in_array($componentType, ['meta', 'device'], true)) { + return 'device'; + } + if ($componentType === 'input') { + return 'inputs'; + } + if (preg_match('/(^|\.)(aenergy|ret_aenergy|energy|[a-z_]+_energy|total_act|total_act_ret)(\.|$)/', $path) === 1) { + return 'energy'; + } + if (preg_match('/(^|\.)(apower|aprtpower|power|[a-z_]+_power)(\.|$)/', $path) === 1) { + return 'power'; + } + if (preg_match('/(^|\.)(voltage|[a-z]_voltage)(\.|$)/', $path) === 1) { + return 'voltage'; + } + if (preg_match('/(^|\.)(current|[a-z]_current)(\.|$)/', $path) === 1) { + return 'current'; + } + if (preg_match('/(^|\.)(freq|[a-z]_freq)(\.|$)/', $path) === 1) { + return 'frequency'; + } + if (preg_match('/(^|\.)(pf|[a-z]_pf)(\.|$)/', $path) === 1) { + return 'power_factor'; + } + if (preg_match('/(^|\.)(temperature|tc|tf)(\.|$)/', $path) === 1 || $componentType === 'temperature') { + return 'temperature'; + } + if (preg_match('/(^|\.)(humidity|rh)(\.|$)/', $path) === 1 || $componentType === 'humidity') { + return 'humidity'; + } + if (preg_match('/(^|\.)(illuminance|lux)(\.|$)/', $path) === 1 || $componentType === 'illuminance') { + return 'illuminance'; + } + if (preg_match('/(^|\.)(battery)(\.|$)/', $path) === 1 || $componentType === 'devicepower') { + return 'battery'; + } + if ($componentType === 'switch') { + return 'switches'; + } + if ($componentType === 'cover') { + return 'covers'; + } + if (in_array($componentType, ['light', 'rgb', 'rgbw', 'cct', 'rgbcct'], true)) { + return 'lights'; + } + if (in_array($componentType, ['pm1', 'em', 'em1', 'emdata'], true)) { + return 'meters'; + } + if (in_array($componentType, ['smoke', 'flood', 'gas'], true)) { + return 'environment'; + } + if (in_array($componentType, ['sys', 'wifi', 'eth', 'mqtt', 'cloud', 'ws', 'ble', 'modbus'], true)) { + return 'system'; + } + + return 'other'; + } + + /** + * @param bool|int|float|string $value + */ + public static function variableType($value, string $path): int + { + if (is_bool($value)) { + return 0; + } + if (is_string($value)) { + return 3; + } + if ((is_int($value) || is_float($value)) + && preg_match('/(^|\.)(id|ts|uptime|unixtime|[a-z_]+_ts|[a-z_]+_rev|rssi)$/i', $path) === 1) { + return 1; + } + + return 2; } } diff --git a/ShellyModul/module.php b/ShellyModul/module.php index 0c55605..ca0ea3a 100644 --- a/ShellyModul/module.php +++ b/ShellyModul/module.php @@ -9,11 +9,54 @@ class ShellyModul extends IPSModule private const MQTT_SERVER_MODULE_ID = '{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}'; private const MQTT_TX_DATA_ID = '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}'; + private const GROUP_PROPERTIES = [ + 'device' => '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); } @@ -42,25 +85,32 @@ class ShellyModul extends IPSModule return; } - $topicParts = explode('/', $topic); - if (count($topicParts) < 2) { - return; - } - - $deviceTopic = array_shift($topicParts); - if (!$this->isAcceptedDeviceTopic($deviceTopic)) { + $topicInfo = ShellyParser::parseTopic($topic); + if ($topicInfo === null || !$this->isAcceptedDeviceTopic($topicInfo['deviceTopic'])) { return; } $this->debug('MQTT empfangen', $topic . ' -> ' . $payload); + $deviceTopic = $topicInfo['deviceTopic']; - if ($topicParts === ['online']) { + if ($topicInfo['kind'] === 'online') { $this->handleOnline($deviceTopic, $payload); return; } - if ($topicParts === ['events', 'rpc']) { - $this->handleRpc($deviceTopic, $payload); + $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)); } } @@ -76,7 +126,6 @@ class ShellyModul extends IPSModule 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); } @@ -92,7 +141,7 @@ class ShellyModul extends IPSModule 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.'); + 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.'); @@ -102,18 +151,31 @@ class ShellyModul extends IPSModule 'id' => random_int(1, 2147483647), 'src' => 'enelix-symcon-' . $this->InstanceID, 'method' => 'Switch.Set', - 'params' => [ - 'id' => $Output, - 'on' => $Value, - ], + '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 { - $value = ShellyParser::toBoolean($payload); + if (!$this->ReadPropertyBoolean('CreateOnline')) { + return; + } + + $value = $this->toBoolean($payload); if ($value === null) { $this->debug('Online-Status ignoriert', $payload); return; @@ -125,77 +187,158 @@ class ShellyModul extends IPSModule } } - private function handleRpc(string $deviceTopic, string $payload): void + private function handleRpc(string $deviceTopic, array $rpc): 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; + 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); + } + } } - $params = $rpc['params'] ?? null; - if (!is_array($params)) { - return; - } + $this->updatePoints($deviceTopic, ShellyParser::pointsFromRpc($rpc)); + } - $typeID = $this->ensureVariable($deviceTopic, 'type', 'Typ', 3, '', 20); - if ($typeID > 0) { - SetValue($typeID, ShellyParser::extractType($source)); - } + /** + * @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; + } - $mapped = ShellyParser::mapParams($params); - foreach ($mapped['inputs'] as $index => $value) { + $definition = $this->pointDefinition($deviceTopic, $point); $variableID = $this->ensureVariable( $deviceTopic, - 'input_' . $index, - 'Input ' . $index, - 0, - '', - 100 + $index + $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); } } + } - 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); - } + 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 ($mapped['temperature'] !== null) { - $variableID = $this->ensureVariable( - $deviceTopic, - 'temperature', - 'Temperatur', - 2, - '~Temperature', - 300 - ); - if ($variableID > 0) { - SetValue($variableID, $mapped['temperature']); - } + 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( @@ -224,7 +367,7 @@ class ShellyModul extends IPSModule IPS_SetName($variableID, $name); IPS_SetPosition($variableID, $position); - if ($profile !== '') { + if ($profile !== '' && IPS_VariableProfileExists($profile)) { IPS_SetVariableCustomProfile($variableID, $profile); } if ($action) { @@ -258,7 +401,6 @@ class ShellyModul extends IPSModule { $ident = 'action_handler'; $scriptID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); - if ($scriptID === false) { $scriptID = IPS_CreateScript(0); IPS_SetParent($scriptID, $this->InstanceID); @@ -310,14 +452,14 @@ PHP private function isAcceptedDeviceTopic(string $deviceTopic): bool { - if ($deviceTopic === '' || strlen($deviceTopic) > 128) { + if ($deviceTopic === '' || strlen($deviceTopic) > 300 || preg_match('//u', $deviceTopic) !== 1) { return false; } - if (preg_match('//u', $deviceTopic) !== 1) { + if (preg_match('/[\x00-\x1F\x7F#\+]/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')); @@ -336,15 +478,28 @@ PHP 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); } } diff --git a/docs/module/Shelly-Modul/README.md b/docs/module/Shelly-Modul/README.md index ab71c69..262bd08 100644 --- a/docs/module/Shelly-Modul/README.md +++ b/docs/module/Shelly-Modul/README.md @@ -1,58 +1,87 @@ # Shelly Modul -> 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. +> Status: Implementiert. Der Parser verarbeitet Shelly-NG-Komponenten der +> Generationen 2, 3 und 4 generisch und unabhängig vom Gerätenamen. -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. +Das Modul verarbeitet Shelly-RPC- und Statusmeldungen über das native +IP-Symcon-MQTT-Datenflussinterface. Es gehört zu Enelix Utils und besitzt +keine Abhängigkeit zum Enelix EMS. ## 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 +- freie MQTT-Client-ID und freie ein- oder mehrstufige Topic-Präfixe +- automatische Erkennung aller gemeldeten Komponenten und skalaren Werte +- vollständige Speicherung von Listen als JSON-String +- konfigurierbare Erzeugung nach Datenpunktgruppen +- weiterhin schaltbare `Switch.Set`-Ausgänge +- keine Verwaltung von Broker- oder Shelly-Zugangsdaten +- keine Shelly-Gen1-`shellies/...`-Topics + +Die generische Verarbeitung ist absichtlich nicht an eine statische Liste von +Gerätemodellen gekoppelt. Neue Komponenten landen in der Gruppe `Other` und +können damit ohne Moduländerung eingelesen werden. ## 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` | +| Shelly nach Symcon | `/online` | Online-Status | +| Shelly nach Symcon | `/events/rpc` | `NotifyStatus` oder `NotifyEvent` | +| Shelly nach Symcon | `/status/` | Komponentenstatus | +| Shelly nach Symcon | `/status` | Gerätestatus | +| Shelly nach Symcon | `/announce` | Geräteinformationen | +| 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 | 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. +Der Gerätepräfix wird vom bekannten Topic-Ende her bestimmt. Dadurch sind +`symcon/events/rpc` und `gebaeude/etage/aktor/events/rpc` gleichermassen +gültig. Die MQTT-Client-ID ist nicht Bestandteil der Erkennungslogik. ## Properties -| Ident | Typ | Standard / Beschreibung | +| Ident | Standard | Beschreibung | | --- | --- | --- | -| `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. | +| `UseDeviceTopicFilter` | `false` | Aktiviert den optionalen Präfixfilter. | +| `DeviceTopicPrefix` | `shelly` | Filterwert; ohne aktivierten Filter wirkungslos. | +| `CreateOnline` | `true` | Online-Status. | +| `CreateDeviceInfo` | `true` | Modell- und Geräteinformationen. | +| `CreateInputs` | `true` | Inputs und Eingangsereignisse. | +| `CreateSwitches` | `true` | Schaltausgänge und Schalterstatus. | +| `CreateCovers` | `true` | Cover- und Beschattungswerte. | +| `CreateLights` | `true` | Licht-, Dimm- und Farbwerte. | +| `CreatePower` | `true` | Wirk- und Scheinleistung. | +| `CreateVoltage` | `true` | Spannungswerte. | +| `CreateCurrent` | `true` | Stromwerte. | +| `CreateEnergy` | `true` | Bezogene und zurückgelieferte Energie. | +| `CreateFrequency` | `true` | Netzfrequenz. | +| `CreatePowerFactor` | `true` | Leistungsfaktor. | +| `CreateMeterDetails` | `true` | Weitere Zählerdaten. | +| `CreateTemperature` | `true` | Temperaturwerte. | +| `CreateHumidity` | `true` | Feuchtewerte. | +| `CreateIlluminance` | `true` | Helligkeit und Beleuchtungsstärke. | +| `CreateBattery` | `true` | Batterie- und Versorgungswerte. | +| `CreateEnvironment` | `true` | Weitere Umweltsensoren. | +| `CreateSystem` | `false` | System- und Netzwerkstatus. | +| `CreateOther` | `false` | Unbekannte und zukünftige Komponenten. | +| `Debug` | `false` | Diagnoseausgaben der Instanz. | + +Ein deaktivierter Bereich wird weder neu angelegt noch aktualisiert. Eine +automatische Löschung bestehender Objekte findet nicht statt. + +## Typabbildung + +| Shelly-Wert | IP-Symcon-Typ | +| --- | --- | +| Boolean | Boolean | +| bekannte IDs, Revisionen und Zeitangaben | Integer | +| übrige numerische Messwerte | Float | +| String | String | +| Liste | JSON-String | +| `null` | wird ignoriert | + +Verschachtelte Objekte werden rekursiv in stabile Pfade wie +`switch:0.aenergy.total` zerlegt. Technische Idents enthalten einen kurzen +Hash, damit Sonderzeichen, lange Pfade und ähnlich benannte Punkte nicht +kollidieren. ## Öffentliche Funktion @@ -65,34 +94,21 @@ SHELLY_SetOutput( ): void; ``` -Die Funktion validiert Topic-Präfix und Ausgangsindex. Ein fehlendes oder -inaktives MQTT-Gateway führt zu einer verständlichen Exception. +Die Funktion validiert das Topic und den Ausgangsindex. Ein fehlendes oder +inaktives MQTT-Gateway erzeugt eine verständliche Exception. -## Adaption aus Enelix 1 +## Kompatibilität -| 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 | +Die bestehenden Variablen für Online, Typ, Inputs, Switch-Ausgänge und die +Switch-Temperatur behalten ihre bisherigen Idents. Der frühere Präfixwert +`shelly` wirkt nach dem Update nur noch, wenn der neue Filter explizit +aktiviert wird. -## Betrieb und Diagnose +## Prüfung -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. +- Parser-Tests für freie und mehrstufige Topics +- Tests für unbekannte Modelle und generische Komponenten +- Tests für Gruppenklassifikation, Ereignisse und Typabbildung +- PHP-Syntaxprüfung und JSON-Prüfung +- Praxistest in IP-Symcon 8.0 mit projektseitigen Gen2-, Gen3- und + Gen4-Geräten bleibt nach dem Merge erforderlich diff --git a/tests/ShellyModulTest.php b/tests/ShellyModulTest.php new file mode 100644 index 0000000..6f9153c --- /dev/null +++ b/tests/ShellyModulTest.php @@ -0,0 +1,243 @@ +InstanceID = $instanceID; + $GLOBALS['ipsObjects'][$instanceID] = ['parent' => 0, 'ident' => '', 'name' => 'Modul']; + $this->Create(); + } + + public function Create() + { + } + + protected function RegisterPropertyBoolean(string $name, bool $value): void + { + $this->properties[$name] = $value; + } + + protected function RegisterPropertyString(string $name, string $value): void + { + $this->properties[$name] = $value; + } + + protected function ReadPropertyBoolean(string $name): bool + { + return (bool) $this->properties[$name]; + } + + protected function ReadPropertyString(string $name): string + { + return (string) $this->properties[$name]; + } + + protected function ConnectParent(string $moduleID): void + { + } + + protected function SendDataToParent(string $packet): void + { + $GLOBALS['sentPackets'][] = $packet; + } + + protected function SendDebug(string $title, string $message, int $format): void + { + } + + public function setTestProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + } + + function IPS_GetObjectIDByIdent(string $ident, int $parentID) + { + foreach ($GLOBALS['ipsObjects'] as $id => $object) { + if ($object['parent'] === $parentID && $object['ident'] === $ident) { + return $id; + } + } + return false; + } + + function IPS_CreateCategory(): int + { + return ipsCreateObject('category'); + } + + function IPS_CreateVariable(int $type): int + { + $id = ipsCreateObject('variable'); + $GLOBALS['ipsVariables'][$id] = ['VariableType' => $type]; + return $id; + } + + function IPS_CreateScript(int $type): int + { + return ipsCreateObject('script'); + } + + function ipsCreateObject(string $type): int + { + $id = ++$GLOBALS['ipsNextID']; + $GLOBALS['ipsObjects'][$id] = [ + 'parent' => 0, + 'ident' => '', + 'name' => '', + 'type' => $type, + 'profile' => '', + ]; + return $id; + } + + function IPS_SetParent(int $id, int $parentID): void + { + $GLOBALS['ipsObjects'][$id]['parent'] = $parentID; + } + + function IPS_SetIdent(int $id, string $ident): void + { + $GLOBALS['ipsObjects'][$id]['ident'] = $ident; + } + + function IPS_SetName(int $id, string $name): void + { + $GLOBALS['ipsObjects'][$id]['name'] = $name; + } + + function IPS_SetInfo(int $id, string $info): void + { + } + + function IPS_SetPosition(int $id, int $position): void + { + } + + function IPS_VariableProfileExists(string $profile): bool + { + return in_array($profile, ['~Switch', '~Temperature', '~Humidity'], true); + } + + function IPS_SetVariableCustomProfile(int $id, string $profile): void + { + $GLOBALS['ipsObjects'][$id]['profile'] = $profile; + } + + function IPS_SetVariableCustomAction(int $id, int $scriptID): void + { + } + + function IPS_SetHidden(int $id, bool $hidden): void + { + } + + function IPS_SetScriptContent(int $id, string $content): void + { + } + + function IPS_VariableExists(int $id): bool + { + return isset($GLOBALS['ipsVariables'][$id]); + } + + function IPS_ScriptExists(int $id): bool + { + return ($GLOBALS['ipsObjects'][$id]['type'] ?? '') === 'script'; + } + + function IPS_GetVariable(int $id): array + { + return $GLOBALS['ipsVariables'][$id]; + } + + function SetValue(int $id, $value): void + { + $GLOBALS['ipsValues'][$id] = $value; + } + + require_once __DIR__ . '/../ShellyModul/module.php'; +} + +namespace Belevo\EnelixUtils\Tests { + use PHPUnit\Framework\TestCase; + + final class ShellyModulTest extends TestCase + { + protected function setUp(): void + { + $GLOBALS['ipsNextID'] = 100; + $GLOBALS['ipsObjects'] = []; + $GLOBALS['ipsVariables'] = []; + $GLOBALS['ipsValues'] = []; + $GLOBALS['sentPackets'] = []; + } + + public function testCustomNestedPrefixAndUnknownSourceAreAccepted(): void + { + $module = new \ShellyModul(1); + $module->ReceiveData(json_encode([ + 'PacketType' => 3, + 'Topic' => 'gebaeude/wohnung/symcon/events/rpc', + 'Payload' => json_encode([ + 'src' => 'custom-client-name', + 'method' => 'NotifyStatus', + 'params' => [ + 'switch:0' => ['output' => true, 'apower' => 12.5], + 'cover:0' => ['current_pos' => 37], + 'future:0' => ['new_value' => 99], + ], + ], JSON_THROW_ON_ERROR), + ], JSON_THROW_ON_ERROR)); + + $names = array_column($GLOBALS['ipsObjects'], 'name'); + self::assertContains('gebaeude/wohnung/symcon', $names); + self::assertContains('Output 0', $names); + self::assertContains('Switch 0 - Wirkleistung', $names); + self::assertContains('Cover 0 - Position', $names); + self::assertNotContains('Future 0 - New value', $names); + self::assertContains(true, $GLOBALS['ipsValues']); + self::assertContains(12.5, $GLOBALS['ipsValues']); + } + + public function testComponentStatusUsesEnvironmentProfile(): void + { + $module = new \ShellyModul(1); + $module->ReceiveData(json_encode([ + 'PacketType' => 3, + 'Topic' => 'symcon/status/humidity:0', + 'Payload' => '{"id":0,"rh":54.3}', + ], JSON_THROW_ON_ERROR)); + + $humidity = array_filter( + $GLOBALS['ipsObjects'], + static fn (array $object): bool => $object['name'] === 'Humidity 0 - Luftfeuchtigkeit' + ); + self::assertCount(1, $humidity); + self::assertSame('~Humidity', array_values($humidity)[0]['profile']); + self::assertContains(54.3, $GLOBALS['ipsValues']); + } + + public function testOptionalTopicFilterCanRejectOtherPrefixes(): void + { + $module = new \ShellyModul(1); + $module->setTestProperty('UseDeviceTopicFilter', true); + $module->setTestProperty('DeviceTopicPrefix', 'shelly'); + $module->ReceiveData(json_encode([ + 'PacketType' => 3, + 'Topic' => 'symcon/online', + 'Payload' => 'true', + ], JSON_THROW_ON_ERROR)); + + self::assertCount(1, $GLOBALS['ipsObjects']); + self::assertSame([], $GLOBALS['ipsValues']); + } + } +} diff --git a/tests/ShellyParserTest.php b/tests/ShellyParserTest.php new file mode 100644 index 0000000..99e7a20 --- /dev/null +++ b/tests/ShellyParserTest.php @@ -0,0 +1,117 @@ + 'symcon', 'kind' => 'rpc', 'component' => null], + \ShellyParser::parseTopic('symcon/events/rpc') + ); + self::assertSame( + ['deviceTopic' => 'building/floor/device-1', 'kind' => 'component_status', 'component' => 'pm1:0'], + \ShellyParser::parseTopic('building/floor/device-1/status/pm1:0') + ); + } + + public function testNotifyStatusIsFlattenedWithoutKnowingTheDeviceModel(): void + { + $points = \ShellyParser::pointsFromRpc([ + 'src' => 'custom-source', + 'method' => 'NotifyStatus', + 'params' => [ + 'switch:0' => [ + 'output' => true, + 'apower' => 12.4, + 'aenergy' => ['total' => 42.5, 'by_minute' => [1, 2, 3]], + ], + 'humidity:0' => ['rh' => 56.2], + ], + ]); + + self::assertContains(['component' => 'switch:0', 'path' => 'output', 'value' => true], $points); + self::assertContains(['component' => 'switch:0', 'path' => 'apower', 'value' => 12.4], $points); + self::assertContains(['component' => 'switch:0', 'path' => 'aenergy.total', 'value' => 42.5], $points); + self::assertContains(['component' => 'switch:0', 'path' => 'aenergy.by_minute', 'value' => '[1,2,3]'], $points); + self::assertContains(['component' => 'humidity:0', 'path' => 'rh', 'value' => 56.2], $points); + } + + public function testGroupsSeparateOutputsMetersAndSensors(): void + { + self::assertSame('switches', \ShellyParser::groupForPoint('switch:0', 'output')); + self::assertSame('power', \ShellyParser::groupForPoint('switch:0', 'apower')); + self::assertSame('energy', \ShellyParser::groupForPoint('emdata:0', 'a_total_act_energy')); + self::assertSame('humidity', \ShellyParser::groupForPoint('humidity:0', 'rh')); + self::assertSame('covers', \ShellyParser::groupForPoint('cover:0', 'current_pos')); + self::assertSame('other', \ShellyParser::groupForPoint('future:7', 'new_value')); + } + + public function testNotifyEventsAreMappedToTheirComponent(): void + { + $points = \ShellyParser::pointsFromRpc([ + 'method' => 'NotifyEvent', + 'params' => [ + 'events' => [[ + 'component' => 'input:0', + 'event' => 'single_push', + 'ts' => 123.5, + ]], + ], + ]); + + self::assertContains( + ['component' => 'input:0', 'path' => 'event.event', 'value' => 'single_push'], + $points + ); + } + + public function testNumericMeasurementsUseFloatVariables(): void + { + self::assertSame(2, \ShellyParser::variableType(0, 'apower')); + self::assertSame(1, \ShellyParser::variableType(123, 'uptime')); + self::assertSame(1, \ShellyParser::variableType(123.5, 'ts')); + self::assertSame(0, \ShellyParser::variableType(true, 'output')); + self::assertSame(3, \ShellyParser::variableType('open', 'state')); + } + + public function testConfigurationOffersAllDataPointGroups(): void + { + $json = file_get_contents(__DIR__ . '/../ShellyModul/form.json'); + self::assertNotFalse($json); + $form = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + $encoded = json_encode($form, JSON_THROW_ON_ERROR); + + foreach ([ + 'CreateOnline', + 'CreateDeviceInfo', + 'CreateInputs', + 'CreateSwitches', + 'CreateCovers', + 'CreateLights', + 'CreatePower', + 'CreateVoltage', + 'CreateCurrent', + 'CreateEnergy', + 'CreateFrequency', + 'CreatePowerFactor', + 'CreateMeterDetails', + 'CreateTemperature', + 'CreateHumidity', + 'CreateIlluminance', + 'CreateBattery', + 'CreateEnvironment', + 'CreateSystem', + 'CreateOther', + ] as $property) { + self::assertStringContainsString($property, $encoded); + } + } +}