Shelly Modul generisch erweitern
Tests / test (push) Successful in 46s

This commit is contained in:
dh
2026-09-17 10:19:56 +00:00
parent 4cbec75d56
commit 1baa63f23e
7 changed files with 1006 additions and 309 deletions
+76 -77
View File
@@ -1,107 +1,106 @@
# Shelly Modul # Shelly Modul
Das Modul bindet Shelly-Geräte ab Generation 2 über deren MQTT/RPC-Protokoll Das Modul bindet Shelly-NG-Geräte der Generationen 2, 3 und 4 über MQTT/RPC
in IP-Symcon ein. Es erkennt Geräte und Datenpunkte aus Statusmeldungen in IP-Symcon ein. Es erkennt bekannte und zukünftige Komponenten dynamisch
automatisch und benötigt keine Abhängigkeit zum Enelix EMS. und benötigt keine Abhängigkeit zum Enelix EMS.
## Voraussetzungen ## Voraussetzungen
- IP-Symcon ab Version 8.0 - IP-Symcon ab Version 8.0
- ein eingerichteter IP-Symcon MQTT Server oder MQTT Client - ein eingerichteter IP-Symcon MQTT Server oder MQTT Client
- ein Shelly Gen2+-Gerät mit aktivierter MQTT-Verbindung - ein Shelly Gen2+-Gerät mit aktivierter MQTT-Verbindung
- MQTT-RPC-Ereignisse auf `<Topic>/events/rpc` und der Online-Status auf - aktivierte RPC-Statusmeldungen (`rpc_ntf`)
`<Topic>/online` - optional aktivierte vollständige Statusmeldungen (`status_ntf`)
Shelly-Geräte der ersten Generation mit `shellies/...`-Topics werden nicht Shelly-Geräte der ersten Generation mit `shellies/...`-Topics verwenden ein
unterstützt. 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 MQTT-Client-ID, Brokername, Benutzername und Kennwort werden ausschliesslich
`Shelly Modul` anlegen. im MQTT-Gateway beziehungsweise im Shelly konfiguriert. Das Modul wertet
2. Das gewünschte MQTT-Gateway verbinden. Neue Instanzen verwenden diese Werte nicht aus. Der Shelly-`topic_prefix` darf frei gewählt werden und
standardmässig den internen MQTT Server; für einen externen Broker kann ein auch mehrere Ebenen enthalten, zum Beispiel `symcon` oder
MQTT Client als Gateway gewählt werden. `gebaeude/etage/aktor`.
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 Das Modul erkennt die Geräte-Topics anhand ihrer Endung:
| Property | Standard | Beschreibung | | Topic | Verwendung |
| --- | --- | --- | | --- | --- |
| `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. | | `<Prefix>/online` | Online-Status |
| `Debug` | `false` | Schreibt verarbeitete und gesendete MQTT-Nachrichten in den Instanz-Debug. | | `<Prefix>/events/rpc` | `NotifyStatus` und `NotifyEvent` |
| `<Prefix>/status/<Komponente>` | vollständiger Komponentenstatus |
| `<Prefix>/status` | vollständiger Gerätestatus |
| `<Prefix>/announce` | Geräteinformationen |
Ein benutzerdefinierter Shelly-`topic_prefix` muss mit Ein optionaler Topic-Filter kann in den Moduleinstellungen aktiviert werden.
`DeviceTopicPrefix` übereinstimmen. Das Feld ist ein einfacher Präfix und Ohne aktivierten Filter werden alle gültigen Shelly-RPC-Topics verarbeitet.
kein regulärer Ausdruck.
## Automatisch angelegte Objekte ## Datenpunktauswahl
Für jede erste MQTT-Topic-Ebene wird ein Geräteordner angelegt. Technische In der Konfiguration kann für jede Gruppe festgelegt werden, ob neue
Idents bestehen nur aus den von IP-Symcon erlaubten Zeichen und enthalten Variablen angelegt und vorhandene Variablen aktualisiert werden:
zusätzlich einen kurzen Hash des originalen Topic-Namens. Damit bleiben auch
Namen mit Bindestrichen stabil und kollisionsarm.
| Anzeige | Typ / Zugriff | MQTT-Quelle | - Online-Status und Geräteinformationen
| --- | --- | --- | - Eingänge und Eingangsereignisse
| Online | Boolean / Anzeige | `<Topic>/online` | - Schaltausgänge
| Typ | String / Anzeige | `src` einer RPC-Meldung | - Rollladen und Beschattung
| Input n | Boolean / Anzeige | `input:n.state` oder `switch:n.input` | - Licht, Dimmer und Farbe
| Output n | Boolean / bedienbar | `switch:n.output` | - Leistung, Spannung, Strom, Energie, Frequenz und Leistungsfaktor einzeln
| Temperatur | Float / Anzeige | erster erkannter Celsiuswert in `temperature`/`tC` | - 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 Systemdaten und unbekannte Datenpunkte sind standardmässig deaktiviert, damit
nicht automatisch gelöscht, wenn ein Datenpunkt später nicht mehr gemeldet nicht unerwartet sehr viele Variablen entstehen. Bereits angelegte Variablen
wird. 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 ## Ausgänge schalten
Eine Bedienung von `Output n` veröffentlicht einen Shelly-RPC-Aufruf Erkannte `switch:<n>.output`-Variablen bleiben bedienbar. Eine Bedienung
`Switch.Set` auf `<Topic>/rpc`. Der Variablenwert wird nicht optimistisch veröffentlicht `Switch.Set` auf `<Prefix>/rpc`. Der Variablenwert wird erst
gesetzt; er folgt der nächsten Statusmeldung des Geräts. Dadurch zeigt mit der nächsten Gerätemeldung aktualisiert.
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 ```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 ## Einrichtung
nach folgendem Muster:
```json 1. Instanz `Shelly Modul` aus Enelix Utils anlegen.
{ 2. Internen MQTT Server oder kompatiblen MQTT Client als Gateway verbinden.
"src": "shellyplus1pm-a1b2c3", 3. MQTT am Shelly aktivieren und RPC-Statusmeldungen einschalten.
"method": "NotifyStatus", 4. Gewünschte Datenpunktgruppen auswählen und Änderungen übernehmen.
"params": { 5. Am Gerät einen Statuswechsel auslösen.
"switch:0": {
"output": true,
"temperature": {"tC": 42.5}
},
"input:0": {"state": false}
}
}
```
Unvollständige, ungültige oder nicht zum konfigurierten Topic-Präfix passende Bei einem externen MQTT-Broker muss der MQTT Client die passenden Topics
Nachrichten werden ignoriert. Bei aktiviertem Debug werden fehlerhafte abonnieren. Zugangsdaten gehören niemals in die Modulkonfiguration.
JSON-Payloads und ignorierte Online-Werte nachvollziehbar protokolliert.
## Hinweise zur Adaption ## Migration
Aus `Shelly_Parser_MQTT` von Enelix 1 wurden MQTT/RPC-Datenfluss, Der bisherige Wert `DeviceTopicPrefix = shelly` bleibt erhalten, wird aber nur
Geräteordner, dynamische Inputs/Outputs, Temperatur und `Switch.Set` noch ausgewertet, wenn `UseDeviceTopicFilter` aktiviert ist. Bestehende
fachlich übernommen. Neu implementiert wurden gültige stabile Idents, Online-, Typ-, Input-, Output- und Temperaturvariablen werden weiterverwendet.
tatsächlich schaltbares Debug-Logging, strikte Payload-Prüfung, ein
konfigurierbarer Topic-Präfix sowie verständliche Fehler bei einem inaktiven ## Diagnose
MQTT-Gateway. Die pauschale MQTT-`#`-Subscription und dauerhaftes
System-Logging wurden verworfen. 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.
+34 -3
View File
@@ -2,12 +2,43 @@
"elements": [ "elements": [
{ {
"type": "Label", "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", "type": "ValidationTextBox",
"name": "DeviceTopicPrefix", "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", "type": "CheckBox",
@@ -18,7 +49,7 @@
"actions": [ "actions": [
{ {
"type": "Label", "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."
} }
] ]
} }
+221 -85
View File
@@ -5,8 +5,38 @@ declare(strict_types=1);
final class ShellyParser 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 public static function extractType(string $source): string
{ {
if (preg_match('/^shelly([a-z0-9]+)-/i', trim($source), $matches) !== 1) { if (preg_match('/^shelly([a-z0-9]+)-/i', trim($source), $matches) !== 1) {
@@ -17,120 +47,226 @@ final class ShellyParser
} }
/** /**
* @return array{outputs: array<int, bool>, inputs: array<int, bool>, temperature: ?float} * @return array<int, array{component: string, path: string, value: bool|int|float|string}>
*/ */
public static function mapParams(array $params): array public static function pointsFromRpc(array $rpc): array
{ {
$mapped = [ $method = $rpc['method'] ?? null;
'outputs' => [], $params = $rpc['params'] ?? null;
'inputs' => [], if (!is_string($method) || !is_array($params)) {
'temperature' => null, return [];
]; }
foreach ($params as $component => $value) { if ($method === 'NotifyStatus') {
return self::pointsFromStatus($params);
}
if ($method === 'NotifyEvent') {
return self::pointsFromEvents($params);
}
return [];
}
/**
* @return array<int, array{component: string, path: string, value: bool|int|float|string}>
*/
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)) { if (!is_string($component) || !is_array($value)) {
continue; continue;
} }
if (preg_match('/^switch:(\d+)$/', $component, $matches) === 1) { self::flatten($component, $value, '', $points);
$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); return $points;
ksort($mapped['outputs']);
ksort($mapped['inputs']);
return $mapped;
} }
public static function toBoolean($value): ?bool /**
* @return array<int, array{component: string, path: string, value: bool|int|float|string}>
*/
public static function pointsFromComponent(string $component, array $status): array
{ {
if (is_bool($value)) { $points = [];
return $value; self::flatten($component, $status, '', $points);
}
if ($value === 1 || $value === '1') { return $points;
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 /**
* @return array<int, array{component: string, path: string, value: bool|int|float|string}>
*/
public static function pointsFromAnnounce(array $announce): array
{
$points = [];
self::flatten('device', $announce, '', $points);
return $points;
}
/**
* @return array<int, array{component: string, path: string, value: bool|int|float|string}>
*/
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<int, array{component: string, path: string, value: bool|int|float|string}> $points
*/
private static function flatten(string $component, array $data, string $prefix, array &$points): void
{ {
foreach ($data as $key => $value) { foreach ($data as $key => $value) {
if (!is_string($key)) { $key = (string) $key;
$path = $prefix === '' ? $key : $prefix . '.' . $key;
if ($value === null) {
continue; 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)) { if (is_array($value)) {
$temperature = self::findTemperature($value); if (self::isList($value)) {
if ($temperature !== null) { $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $temperature; 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 ['component' => $component, 'path' => $path, 'value' => $value];
return (float) $value; }
}
if (!is_array($value)) {
return null;
}
foreach (['tC', 'tc', 't'] as $key) { private static function isList(array $value): bool
if (array_key_exists($key, $value) && is_numeric($value[$key])) { {
return (float) $value[$key]; $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;
} }
} }
+233 -78
View File
@@ -9,11 +9,54 @@ class ShellyModul extends IPSModule
private const MQTT_SERVER_MODULE_ID = '{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}'; private const MQTT_SERVER_MODULE_ID = '{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}';
private const MQTT_TX_DATA_ID = '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}'; 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() public function Create()
{ {
parent::Create(); parent::Create();
$this->RegisterPropertyBoolean('UseDeviceTopicFilter', false);
$this->RegisterPropertyString('DeviceTopicPrefix', 'shelly'); $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->RegisterPropertyBoolean('Debug', false);
$this->ConnectParent(self::MQTT_SERVER_MODULE_ID); $this->ConnectParent(self::MQTT_SERVER_MODULE_ID);
} }
@@ -42,25 +85,32 @@ class ShellyModul extends IPSModule
return; return;
} }
$topicParts = explode('/', $topic); $topicInfo = ShellyParser::parseTopic($topic);
if (count($topicParts) < 2) { if ($topicInfo === null || !$this->isAcceptedDeviceTopic($topicInfo['deviceTopic'])) {
return;
}
$deviceTopic = array_shift($topicParts);
if (!$this->isAcceptedDeviceTopic($deviceTopic)) {
return; return;
} }
$this->debug('MQTT empfangen', $topic . ' -> ' . $payload); $this->debug('MQTT empfangen', $topic . ' -> ' . $payload);
$deviceTopic = $topicInfo['deviceTopic'];
if ($topicParts === ['online']) { if ($topicInfo['kind'] === 'online') {
$this->handleOnline($deviceTopic, $payload); $this->handleOnline($deviceTopic, $payload);
return; return;
} }
if ($topicParts === ['events', 'rpc']) { $decoded = $this->decodePayload($payload);
$this->handleRpc($deviceTopic, $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) { if (!IPS_ObjectExists($folderID) || IPS_GetParent($folderID) !== $this->InstanceID) {
throw new InvalidArgumentException('Der Geräteordner gehört nicht zu dieser Instanz.'); throw new InvalidArgumentException('Der Geräteordner gehört nicht zu dieser Instanz.');
} }
if (preg_match('/_output_(\d+)$/', $variableIdent, $matches) !== 1) { if (preg_match('/_output_(\d+)$/', $variableIdent, $matches) !== 1) {
throw new InvalidArgumentException('Unbekannter Shelly-Ausgang: ' . $variableIdent); 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 public function SetOutput(string $DeviceTopic, int $Output, bool $Value): void
{ {
if (!$this->isAcceptedDeviceTopic($DeviceTopic)) { 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) { if ($Output < 0) {
throw new InvalidArgumentException('Der Ausgangsindex darf nicht negativ sein.'); throw new InvalidArgumentException('Der Ausgangsindex darf nicht negativ sein.');
@@ -102,18 +151,31 @@ class ShellyModul extends IPSModule
'id' => random_int(1, 2147483647), 'id' => random_int(1, 2147483647),
'src' => 'enelix-symcon-' . $this->InstanceID, 'src' => 'enelix-symcon-' . $this->InstanceID,
'method' => 'Switch.Set', 'method' => 'Switch.Set',
'params' => [ 'params' => ['id' => $Output, 'on' => $Value],
'id' => $Output,
'on' => $Value,
],
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$this->publish($DeviceTopic . '/rpc', $payload); $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 private function handleOnline(string $deviceTopic, string $payload): void
{ {
$value = ShellyParser::toBoolean($payload); if (!$this->ReadPropertyBoolean('CreateOnline')) {
return;
}
$value = $this->toBoolean($payload);
if ($value === null) { if ($value === null) {
$this->debug('Online-Status ignoriert', $payload); $this->debug('Online-Status ignoriert', $payload);
return; 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'] ?? ''; $source = $rpc['src'] ?? '';
if (!is_string($source) || ShellyParser::extractType($source) === 'unknown') { if ($this->ReadPropertyBoolean('CreateDeviceInfo') && is_string($source)) {
return; $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; $this->updatePoints($deviceTopic, ShellyParser::pointsFromRpc($rpc));
if (!is_array($params)) { }
return;
}
$typeID = $this->ensureVariable($deviceTopic, 'type', 'Typ', 3, '', 20); /**
if ($typeID > 0) { * @param array<int, array{component: string, path: string, value: bool|int|float|string}> $points
SetValue($typeID, ShellyParser::extractType($source)); */
} 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); $definition = $this->pointDefinition($deviceTopic, $point);
foreach ($mapped['inputs'] as $index => $value) {
$variableID = $this->ensureVariable( $variableID = $this->ensureVariable(
$deviceTopic, $deviceTopic,
'input_' . $index, $definition['suffix'],
'Input ' . $index, $definition['name'],
0, $definition['type'],
'', $definition['profile'],
100 + $index $definition['position'],
$definition['action']
); );
if ($variableID > 0) { 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); SetValue($variableID, $value);
} }
} }
}
foreach ($mapped['outputs'] as $index => $value) { private function isGroupEnabled(string $group): bool
$variableID = $this->ensureVariable( {
$deviceTopic, $property = self::GROUP_PROPERTIES[$group] ?? 'CreateOther';
'output_' . $index, return $this->ReadPropertyBoolean($property);
'Output ' . $index, }
0,
'~Switch', /**
200 + $index, * @param array{component: string, path: string, value: bool|int|float|string} $point
true * @return array{suffix: string, name: string, type: int, profile: string, position: int, action: bool}
); */
if ($variableID > 0) { private function pointDefinition(string $deviceTopic, array $point): array
SetValue($variableID, $value); {
} [$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) { if (($componentType === 'input' && $point['path'] === 'state')
$variableID = $this->ensureVariable( || ($componentType === 'switch' && $point['path'] === 'input')) {
$deviceTopic, $index = ctype_digit((string) $componentIndex) ? (int) $componentIndex : 0;
'temperature', return [
'Temperatur', 'suffix' => 'input_' . $index,
2, 'name' => 'Input ' . $index,
'~Temperature', 'type' => 0,
300 'profile' => '',
); 'position' => 100 + $index,
if ($variableID > 0) { 'action' => false,
SetValue($variableID, $mapped['temperature']); ];
}
} }
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( private function ensureVariable(
@@ -224,7 +367,7 @@ class ShellyModul extends IPSModule
IPS_SetName($variableID, $name); IPS_SetName($variableID, $name);
IPS_SetPosition($variableID, $position); IPS_SetPosition($variableID, $position);
if ($profile !== '') { if ($profile !== '' && IPS_VariableProfileExists($profile)) {
IPS_SetVariableCustomProfile($variableID, $profile); IPS_SetVariableCustomProfile($variableID, $profile);
} }
if ($action) { if ($action) {
@@ -258,7 +401,6 @@ class ShellyModul extends IPSModule
{ {
$ident = 'action_handler'; $ident = 'action_handler';
$scriptID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); $scriptID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if ($scriptID === false) { if ($scriptID === false) {
$scriptID = IPS_CreateScript(0); $scriptID = IPS_CreateScript(0);
IPS_SetParent($scriptID, $this->InstanceID); IPS_SetParent($scriptID, $this->InstanceID);
@@ -310,14 +452,14 @@ PHP
private function isAcceptedDeviceTopic(string $deviceTopic): bool private function isAcceptedDeviceTopic(string $deviceTopic): bool
{ {
if ($deviceTopic === '' || strlen($deviceTopic) > 128) { if ($deviceTopic === '' || strlen($deviceTopic) > 300 || preg_match('//u', $deviceTopic) !== 1) {
return false; return false;
} }
if (preg_match('//u', $deviceTopic) !== 1) { if (preg_match('/[\x00-\x1F\x7F#\+]/u', $deviceTopic) === 1) {
return false; return false;
} }
if (preg_match('/[\x00-\x1F\x7F\/#\+]/u', $deviceTopic) === 1) { if (!$this->ReadPropertyBoolean('UseDeviceTopicFilter')) {
return false; return true;
} }
$prefix = trim($this->ReadPropertyString('DeviceTopicPrefix')); $prefix = trim($this->ReadPropertyString('DeviceTopicPrefix'));
@@ -336,15 +478,28 @@ PHP
return substr($normalized, 0, 48) . '_' . substr(hash('sha256', $deviceTopic), 0, 8); 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 private function debug(string $title, $message): void
{ {
if (!$this->ReadPropertyBoolean('Debug')) { if (!$this->ReadPropertyBoolean('Debug')) {
return; return;
} }
if (!is_string($message)) { if (!is_string($message)) {
$message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} }
$this->SendDebug($title, (string) $message, 0); $this->SendDebug($title, (string) $message, 0);
} }
} }
+82 -66
View File
@@ -1,58 +1,87 @@
# Shelly Modul # Shelly Modul
> Status: Implementiert. Die fachliche Funktion von > Status: Implementiert. Der Parser verarbeitet Shelly-NG-Komponenten der
> `Shelly_Parser_MQTT` wurde für IP-Symcon 8.0 bereinigt und ohne > Generationen 2, 3 und 4 generisch und unabhängig vom Gerätenamen.
> EMS-Abhängigkeit in Enelix Utils adaptiert.
Das Modul verarbeitet Shelly Gen2+-Statusmeldungen über das native Das Modul verarbeitet Shelly-RPC- und Statusmeldungen über das native
IP-Symcon-MQTT-Datenflussinterface. Ein interner MQTT Server wird beim IP-Symcon-MQTT-Datenflussinterface. Es gehört zu Enelix Utils und besitzt
Erstellen als Standard-Parent angeboten; ein kompatibler MQTT Client kann keine Abhängigkeit zum Enelix EMS.
manuell als Gateway verbunden werden.
## Ziel und Abgrenzung ## Ziel und Abgrenzung
- automatische Erkennung von Geräten und vorhandenen Schaltkanälen - freie MQTT-Client-ID und freie ein- oder mehrstufige Topic-Präfixe
- Anzeige von Online-Status, Typ, Inputs, Outputs und Temperatur - automatische Erkennung aller gemeldeten Komponenten und skalaren Werte
- Schalten erkannter Outputs über Shelly RPC `Switch.Set` - vollständige Speicherung von Listen als JSON-String
- keine Geräteauswahl, Discovery-Instanz oder EMS-Kopplung - konfigurierbare Erzeugung nach Datenpunktgruppen
- keine Unterstützung für Shelly Gen1-`shellies/...`-Topics - weiterhin schaltbare `Switch.Set`-Ausgänge
- keine Verwaltung von MQTT-Zugangsdaten oder Shelly-Gerätekonfiguration - 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 ## MQTT-Datenfluss
| Richtung | Topic | Inhalt | | Richtung | Topic | Inhalt |
| --- | --- | --- | | --- | --- | --- |
| Shelly nach Symcon | `<Topic>/online` | `true`/`false`, `1`/`0` oder `online`/`offline` | | Shelly nach Symcon | `<Prefix>/online` | Online-Status |
| Shelly nach Symcon | `<Topic>/events/rpc` | RPC-Payload mit `src` und `params` | | Shelly nach Symcon | `<Prefix>/events/rpc` | `NotifyStatus` oder `NotifyEvent` |
| Symcon nach Shelly | `<Topic>/rpc` | RPC-Aufruf `Switch.Set` | | Shelly nach Symcon | `<Prefix>/status/<Komponente>` | Komponentenstatus |
| Shelly nach Symcon | `<Prefix>/status` | Gerätestatus |
| Shelly nach Symcon | `<Prefix>/announce` | Geräteinformationen |
| Symcon nach Shelly | `<Prefix>/rpc` | RPC-Aufruf `Switch.Set` |
Nur MQTT-Publish-Pakete mit `PacketType = 3` werden ausgewertet. Die erste Der Gerätepräfix wird vom bekannten Topic-Ende her bestimmt. Dadurch sind
Topic-Ebene wird als Geräte-Topic verwendet. Der konfigurierte Präfix wird `symcon/events/rpc` und `gebaeude/etage/aktor/events/rpc` gleichermassen
ohne Beachtung der Gross-/Kleinschreibung geprüft. gültig. Die MQTT-Client-ID ist nicht Bestandteil der Erkennungslogik.
## Dynamische Variablen
`<Schlüssel>` ist ein aus dem Geräte-Topic abgeleiteter, für IP-Symcon
gültiger Ident-Bestandteil mit kurzem Hash.
| Ident-Muster | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `<Schlüssel>_online` | Boolean / Anzeige | Online-Meldung. |
| `<Schlüssel>_type` | String / Anzeige | Aus der RPC-`src` extrahierter Modellteil. |
| `<Schlüssel>_input_n` | Boolean / Anzeige | `input:n.state` oder `switch:n.input`. |
| `<Schlüssel>_output_n` | Boolean / bedienbar | `switch:n.output`; schaltet per RPC. |
| `<Schlüssel>_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 ## 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. | | `UseDeviceTopicFilter` | `false` | Aktiviert den optionalen Präfixfilter. |
| `Debug` | Boolean | `false`; Instanz-Debug für empfangene/gesendete MQTT-Daten und Validierungsfehler. | | `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 ## Öffentliche Funktion
@@ -65,34 +94,21 @@ SHELLY_SetOutput(
): void; ): void;
``` ```
Die Funktion validiert Topic-Präfix und Ausgangsindex. Ein fehlendes oder Die Funktion validiert das Topic und den Ausgangsindex. Ein fehlendes oder
inaktives MQTT-Gateway führt zu einer verständlichen Exception. inaktives MQTT-Gateway erzeugt eine verständliche Exception.
## Adaption aus Enelix 1 ## Kompatibilität
| Teil | Entscheidung | Die bestehenden Variablen für Online, Typ, Inputs, Switch-Ausgänge und die
| --- | --- | Switch-Temperatur behalten ihre bisherigen Idents. Der frühere Präfixwert
| MQTT-Datenfluss-GUIDs und RPC-`Switch.Set` | angepasst übernommen | `shelly` wirkt nach dem Update nur noch, wenn der neue Filter explizit
| dynamische Geräteordner und Datenpunkte | angepasst übernommen | aktiviert wird.
| 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 ## Prüfung
1. MQTT-Gateway muss verbunden und aktiv sein. - Parser-Tests für freie und mehrstufige Topics
2. Shelly MQTT muss RPC-Statusmeldungen publizieren. - Tests für unbekannte Modelle und generische Komponenten
3. Geräte-Topic und `DeviceTopicPrefix` müssen zusammenpassen. - Tests für Gruppenklassifikation, Ereignisse und Typabbildung
4. Bei fehlenden Variablen kurzzeitig `Debug` aktivieren und eine - PHP-Syntaxprüfung und JSON-Prüfung
Statusänderung auslösen. - Praxistest in IP-Symcon 8.0 mit projektseitigen Gen2-, Gen3- und
5. Zugangsdaten niemals in Logs oder Repository-Dokumentation übernehmen. Gen4-Geräten bleibt nach dem Merge erforderlich
## 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.
+243
View File
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace {
class IPSModule
{
protected int $InstanceID;
private array $properties = [];
public function __construct(int $instanceID = 1)
{
$this->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']);
}
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../ShellyModul/libs/ShellyParser.php';
final class ShellyParserTest extends TestCase
{
public function testCustomAndNestedTopicPrefixesAreRecognized(): void
{
self::assertSame(
['deviceTopic' => '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);
}
}
}