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
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 `<Topic>/events/rpc` und der Online-Status auf
`<Topic>/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 |
| --- | --- |
| `<Prefix>/online` | Online-Status |
| `<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
`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 | `<Topic>/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 `<Topic>/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:<n>.output`-Variablen bleiben bedienbar. Eine Bedienung
veröffentlicht `Switch.Set` auf `<Prefix>/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.
+34 -3
View File
@@ -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."
}
]
}
+221 -85
View File
@@ -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<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 = [
'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<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)) {
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<int, array{component: string, path: string, value: bool|int|float|string}>
*/
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<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) {
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;
}
}
+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_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<int, array{component: string, path: string, value: bool|int|float|string}> $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);
}
}