Shelly MQTT Modul implementieren
Tests / test (push) Successful in 42s

This commit is contained in:
dh
2026-09-17 09:46:07 +00:00
parent b7c4b16987
commit 4cbec75d56
9 changed files with 726 additions and 24 deletions
+107
View File
@@ -0,0 +1,107 @@
# Shelly Modul
Das Modul bindet Shelly-Geräte ab Generation 2 über deren MQTT/RPC-Protokoll
in IP-Symcon ein. Es erkennt Geräte und Datenpunkte aus Statusmeldungen
automatisch und benötigt keine Abhängigkeit zum Enelix EMS.
## Voraussetzungen
- IP-Symcon ab Version 8.0
- ein eingerichteter IP-Symcon MQTT Server oder MQTT Client
- ein Shelly Gen2+-Gerät mit aktivierter MQTT-Verbindung
- MQTT-RPC-Ereignisse auf `<Topic>/events/rpc` und der Online-Status auf
`<Topic>/online`
Shelly-Geräte der ersten Generation mit `shellies/...`-Topics werden nicht
unterstützt.
## Einrichtung
1. Das Modul über die Enelix-Utils-Bibliothek installieren und eine Instanz
`Shelly Modul` anlegen.
2. Das gewünschte MQTT-Gateway verbinden. Neue Instanzen verwenden
standardmässig den internen MQTT Server; für einen externen Broker kann ein
MQTT Client als Gateway gewählt werden.
3. Am Shelly MQTT aktivieren und den Broker, Port und gegebenenfalls
Zugangsdaten eintragen. Zugangsdaten gehören ausschliesslich in die
MQTT-/Shelly-Konfiguration und nicht in dieses Modul.
4. Den Topic-Präfix prüfen und die Instanz übernehmen.
5. Eine Statusänderung am Gerät auslösen. Der Geräteordner und seine Variablen
werden mit der ersten passenden Meldung angelegt.
## Konfiguration
| Property | Standard | Beschreibung |
| --- | --- | --- |
| `DeviceTopicPrefix` | `shelly` | Nur erste Topic-Ebenen mit diesem Präfix werden verarbeitet; Vergleich ohne Beachtung der Gross-/Kleinschreibung. Leer erlaubt alle gültigen Topic-Namen. |
| `Debug` | `false` | Schreibt verarbeitete und gesendete MQTT-Nachrichten in den Instanz-Debug. |
Ein benutzerdefinierter Shelly-`topic_prefix` muss mit
`DeviceTopicPrefix` übereinstimmen. Das Feld ist ein einfacher Präfix und
kein regulärer Ausdruck.
## Automatisch angelegte Objekte
Für jede erste MQTT-Topic-Ebene wird ein Geräteordner angelegt. Technische
Idents bestehen nur aus den von IP-Symcon erlaubten Zeichen und enthalten
zusätzlich einen kurzen Hash des originalen Topic-Namens. Damit bleiben auch
Namen mit Bindestrichen stabil und kollisionsarm.
| Anzeige | Typ / Zugriff | MQTT-Quelle |
| --- | --- | --- |
| Online | Boolean / Anzeige | `<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` |
Neue Inputs und Outputs werden dynamisch ergänzt. Bestehende Objekte werden
nicht automatisch gelöscht, wenn ein Datenpunkt später nicht mehr gemeldet
wird.
## Ausgänge schalten
Eine Bedienung von `Output n` veröffentlicht einen Shelly-RPC-Aufruf
`Switch.Set` auf `<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:
```php
SHELLY_SetOutput($instanceID, 'shellyplus1pm-a1b2c3', 0, true);
```
## Unterstützte RPC-Struktur
Das Modul verarbeitet Publish-Pakete (`PacketType = 3`) mit JSON-Payloads
nach folgendem Muster:
```json
{
"src": "shellyplus1pm-a1b2c3",
"method": "NotifyStatus",
"params": {
"switch:0": {
"output": true,
"temperature": {"tC": 42.5}
},
"input:0": {"state": false}
}
}
```
Unvollständige, ungültige oder nicht zum konfigurierten Topic-Präfix passende
Nachrichten werden ignoriert. Bei aktiviertem Debug werden fehlerhafte
JSON-Payloads und ignorierte Online-Werte nachvollziehbar protokolliert.
## Hinweise zur Adaption
Aus `Shelly_Parser_MQTT` von Enelix 1 wurden MQTT/RPC-Datenfluss,
Geräteordner, dynamische Inputs/Outputs, Temperatur und `Switch.Set`
fachlich übernommen. Neu implementiert wurden gültige stabile Idents,
tatsächlich schaltbares Debug-Logging, strikte Payload-Prüfung, ein
konfigurierbarer Topic-Präfix sowie verständliche Fehler bei einem inaktiven
MQTT-Gateway. Die pauschale MQTT-`#`-Subscription und dauerhaftes
System-Logging wurden verworfen.
+24
View File
@@ -0,0 +1,24 @@
{
"elements": [
{
"type": "Label",
"caption": "Verarbeitet MQTT/RPC-Statusmeldungen von Shelly Gen2+ und legt erkannte Geräte und Datenpunkte automatisch an."
},
{
"type": "ValidationTextBox",
"name": "DeviceTopicPrefix",
"caption": "Erlaubter MQTT-Topic-Präfix"
},
{
"type": "CheckBox",
"name": "Debug",
"caption": "Debug-Ausgaben aktivieren"
}
],
"actions": [
{
"type": "Label",
"caption": "MQTT wird am Shelly-Gerät konfiguriert. Standardmässig wird der interne MQTT Server verwendet; ein vorhandener MQTT Client kann als Gateway gewählt werden."
}
]
}
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
final class ShellyParser
{
/**
* Extrahiert den Modellteil aus einer Shelly-RPC-Source.
*/
public static function extractType(string $source): string
{
if (preg_match('/^shelly([a-z0-9]+)-/i', trim($source), $matches) !== 1) {
return 'unknown';
}
return strtolower($matches[1]);
}
/**
* @return array{outputs: array<int, bool>, inputs: array<int, bool>, temperature: ?float}
*/
public static function mapParams(array $params): array
{
$mapped = [
'outputs' => [],
'inputs' => [],
'temperature' => null,
];
foreach ($params as $component => $value) {
if (!is_string($component) || !is_array($value)) {
continue;
}
if (preg_match('/^switch:(\d+)$/', $component, $matches) === 1) {
$index = (int) $matches[1];
$output = self::toBoolean($value['output'] ?? null);
$input = self::toBoolean($value['input'] ?? null);
if ($output !== null) {
$mapped['outputs'][$index] = $output;
}
if ($input !== null) {
$mapped['inputs'][$index] = $input;
}
}
if (preg_match('/^input:(\d+)$/', $component, $matches) === 1) {
$state = self::toBoolean($value['state'] ?? null);
if ($state !== null) {
$mapped['inputs'][(int) $matches[1]] = $state;
}
}
}
$mapped['temperature'] = self::findTemperature($params);
ksort($mapped['outputs']);
ksort($mapped['inputs']);
return $mapped;
}
public static function toBoolean($value): ?bool
{
if (is_bool($value)) {
return $value;
}
if ($value === 1 || $value === '1') {
return true;
}
if ($value === 0 || $value === '0') {
return false;
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['true', 'on', 'online'], true)) {
return true;
}
if (in_array($normalized, ['false', 'off', 'offline'], true)) {
return false;
}
}
return null;
}
private static function findTemperature(array $data): ?float
{
foreach ($data as $key => $value) {
if (!is_string($key)) {
continue;
}
$lowerKey = strtolower($key);
if ($lowerKey === 'temperature') {
$temperature = self::temperatureFromValue($value);
if ($temperature !== null) {
return $temperature;
}
}
if ($lowerKey === 'tc' && is_numeric($value)) {
return (float) $value;
}
if (is_array($value)) {
$temperature = self::findTemperature($value);
if ($temperature !== null) {
return $temperature;
}
}
}
return null;
}
private static function temperatureFromValue($value): ?float
{
if (is_numeric($value)) {
return (float) $value;
}
if (!is_array($value)) {
return null;
}
foreach (['tC', 'tc', 't'] as $key) {
if (array_key_exists($key, $value) && is_numeric($value[$key])) {
return (float) $value[$key];
}
}
return null;
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "{21D82EDB-AC8E-4F3D-9FEE-D20BCAFC1C33}",
"name": "Shelly Modul",
"type": 3,
"vendor": "Belevo AG",
"aliases": [
"Shelly MQTT Parser"
],
"parentRequirements": [
"{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}"
],
"childRequirements": [],
"implemented": [
"{7F7632D9-FA40-4F38-8DEA-C83CD4325A32}"
],
"prefix": "SHELLY",
"url": ""
}
+350
View File
@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/libs/ShellyParser.php';
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}';
public function Create()
{
parent::Create();
$this->RegisterPropertyString('DeviceTopicPrefix', 'shelly');
$this->RegisterPropertyBoolean('Debug', false);
$this->ConnectParent(self::MQTT_SERVER_MODULE_ID);
}
public function ApplyChanges()
{
parent::ApplyChanges();
}
public function ReceiveData($JSONString)
{
try {
$data = json_decode((string) $JSONString, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->debug('Ungültiges MQTT-Datenpaket', $exception->getMessage());
return;
}
if (!is_array($data) || (int) ($data['PacketType'] ?? 0) !== 3) {
return;
}
$topic = $data['Topic'] ?? null;
$payload = $data['Payload'] ?? null;
if (!is_string($topic) || !is_string($payload)) {
return;
}
$topicParts = explode('/', $topic);
if (count($topicParts) < 2) {
return;
}
$deviceTopic = array_shift($topicParts);
if (!$this->isAcceptedDeviceTopic($deviceTopic)) {
return;
}
$this->debug('MQTT empfangen', $topic . ' -> ' . $payload);
if ($topicParts === ['online']) {
$this->handleOnline($deviceTopic, $payload);
return;
}
if ($topicParts === ['events', 'rpc']) {
$this->handleRpc($deviceTopic, $payload);
}
}
public function RequestAction($Ident, $Value)
{
$parts = explode(':', (string) $Ident, 2);
if (count($parts) !== 2 || !ctype_digit($parts[0])) {
throw new InvalidArgumentException('Ungültige Shelly-Aktion: ' . (string) $Ident);
}
$folderID = (int) $parts[0];
$variableIdent = $parts[1];
if (!IPS_ObjectExists($folderID) || IPS_GetParent($folderID) !== $this->InstanceID) {
throw new InvalidArgumentException('Der Geräteordner gehört nicht zu dieser Instanz.');
}
if (preg_match('/_output_(\d+)$/', $variableIdent, $matches) !== 1) {
throw new InvalidArgumentException('Unbekannter Shelly-Ausgang: ' . $variableIdent);
}
$variableID = @IPS_GetObjectIDByIdent($variableIdent, $folderID);
if ($variableID === false || !IPS_VariableExists($variableID)) {
throw new InvalidArgumentException('Die Ausgangsvariable wurde nicht gefunden.');
}
$this->SetOutput(IPS_GetName($folderID), (int) $matches[1], (bool) $Value);
}
public function SetOutput(string $DeviceTopic, int $Output, bool $Value): void
{
if (!$this->isAcceptedDeviceTopic($DeviceTopic)) {
throw new InvalidArgumentException('Das Geräte-Topic entspricht nicht dem konfigurierten Präfix.');
}
if ($Output < 0) {
throw new InvalidArgumentException('Der Ausgangsindex darf nicht negativ sein.');
}
$payload = json_encode([
'id' => random_int(1, 2147483647),
'src' => 'enelix-symcon-' . $this->InstanceID,
'method' => 'Switch.Set',
'params' => [
'id' => $Output,
'on' => $Value,
],
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$this->publish($DeviceTopic . '/rpc', $payload);
}
private function handleOnline(string $deviceTopic, string $payload): void
{
$value = ShellyParser::toBoolean($payload);
if ($value === null) {
$this->debug('Online-Status ignoriert', $payload);
return;
}
$variableID = $this->ensureVariable($deviceTopic, 'online', 'Online', 0, '', 10);
if ($variableID > 0) {
SetValue($variableID, $value);
}
}
private function handleRpc(string $deviceTopic, string $payload): void
{
try {
$rpc = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->debug('Ungültiges Shelly-RPC', $exception->getMessage());
return;
}
if (!is_array($rpc)) {
return;
}
$source = $rpc['src'] ?? '';
if (!is_string($source) || ShellyParser::extractType($source) === 'unknown') {
return;
}
$params = $rpc['params'] ?? null;
if (!is_array($params)) {
return;
}
$typeID = $this->ensureVariable($deviceTopic, 'type', 'Typ', 3, '', 20);
if ($typeID > 0) {
SetValue($typeID, ShellyParser::extractType($source));
}
$mapped = ShellyParser::mapParams($params);
foreach ($mapped['inputs'] as $index => $value) {
$variableID = $this->ensureVariable(
$deviceTopic,
'input_' . $index,
'Input ' . $index,
0,
'',
100 + $index
);
if ($variableID > 0) {
SetValue($variableID, $value);
}
}
foreach ($mapped['outputs'] as $index => $value) {
$variableID = $this->ensureVariable(
$deviceTopic,
'output_' . $index,
'Output ' . $index,
0,
'~Switch',
200 + $index,
true
);
if ($variableID > 0) {
SetValue($variableID, $value);
}
}
if ($mapped['temperature'] !== null) {
$variableID = $this->ensureVariable(
$deviceTopic,
'temperature',
'Temperatur',
2,
'~Temperature',
300
);
if ($variableID > 0) {
SetValue($variableID, $mapped['temperature']);
}
}
}
private function ensureVariable(
string $deviceTopic,
string $suffix,
string $name,
int $type,
string $profile,
int $position,
bool $action = false
): int {
$folderID = $this->ensureDeviceFolder($deviceTopic);
$ident = $this->deviceKey($deviceTopic) . '_' . $suffix;
$variableID = @IPS_GetObjectIDByIdent($ident, $folderID);
if ($variableID !== false) {
if (!IPS_VariableExists($variableID) || IPS_GetVariable($variableID)['VariableType'] !== $type) {
$this->debug('Variablentyp-Konflikt', $ident);
return 0;
}
} else {
$variableID = IPS_CreateVariable($type);
IPS_SetParent($variableID, $folderID);
IPS_SetIdent($variableID, $ident);
}
IPS_SetName($variableID, $name);
IPS_SetPosition($variableID, $position);
if ($profile !== '') {
IPS_SetVariableCustomProfile($variableID, $profile);
}
if ($action) {
$scriptID = $this->ensureActionScript();
if ($scriptID > 0) {
IPS_SetVariableCustomAction($variableID, $scriptID);
}
}
return $variableID;
}
private function ensureDeviceFolder(string $deviceTopic): int
{
$ident = 'folder_' . $this->deviceKey($deviceTopic);
$folderID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if ($folderID !== false) {
return $folderID;
}
$folderID = IPS_CreateCategory();
IPS_SetParent($folderID, $this->InstanceID);
IPS_SetIdent($folderID, $ident);
IPS_SetName($folderID, $deviceTopic);
IPS_SetInfo($folderID, 'Automatisch aus MQTT-Topic ' . $deviceTopic . ' angelegt.');
return $folderID;
}
private function ensureActionScript(): int
{
$ident = 'action_handler';
$scriptID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if ($scriptID === false) {
$scriptID = IPS_CreateScript(0);
IPS_SetParent($scriptID, $this->InstanceID);
IPS_SetIdent($scriptID, $ident);
} elseif (!IPS_ScriptExists($scriptID)) {
$this->debug('Action Handler', 'Ident ist bereits durch ein anderes Objekt belegt.');
return 0;
}
IPS_SetName($scriptID, 'Shelly Action Handler');
IPS_SetHidden($scriptID, true);
IPS_SetScriptContent($scriptID, <<<'PHP'
<?php
$variableID = $_IPS['VARIABLE'];
$folderID = IPS_GetParent($variableID);
$moduleID = IPS_GetParent($folderID);
$ident = IPS_GetObject($variableID)['ObjectIdent'];
IPS_RequestAction($moduleID, $folderID . ':' . $ident, $_IPS['VALUE']);
PHP
);
return $scriptID;
}
private function publish(string $topic, string $payload): void
{
$parentID = (int) (IPS_GetInstance($this->InstanceID)['ConnectionID'] ?? 0);
if ($parentID === 0 || !IPS_InstanceExists($parentID)) {
throw new RuntimeException('Kein MQTT-Gateway verbunden.');
}
if ((int) IPS_GetInstance($parentID)['InstanceStatus'] !== 102) {
throw new RuntimeException('Das verbundene MQTT-Gateway ist nicht aktiv.');
}
$packet = json_encode([
'DataID' => self::MQTT_TX_DATA_ID,
'PacketType' => 3,
'QualityOfService' => 0,
'Retain' => false,
'Topic' => $topic,
'Payload' => $payload,
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$this->SendDataToParent($packet);
$this->debug('MQTT gesendet', $topic . ' -> ' . $payload);
}
private function isAcceptedDeviceTopic(string $deviceTopic): bool
{
if ($deviceTopic === '' || strlen($deviceTopic) > 128) {
return false;
}
if (preg_match('//u', $deviceTopic) !== 1) {
return false;
}
if (preg_match('/[\x00-\x1F\x7F\/#\+]/u', $deviceTopic) === 1) {
return false;
}
$prefix = trim($this->ReadPropertyString('DeviceTopicPrefix'));
return $prefix === '' || strncasecmp($deviceTopic, $prefix, strlen($prefix)) === 0;
}
private function deviceKey(string $deviceTopic): string
{
$normalized = strtolower($deviceTopic);
$normalized = preg_replace('/[^a-z0-9_]+/', '_', $normalized) ?? 'device';
$normalized = trim($normalized, '_');
if ($normalized === '') {
$normalized = 'device';
}
return substr($normalized, 0, 48) . '_' . substr(hash('sha256', $deviceTopic), 0, 8);
}
private function debug(string $title, $message): void
{
if (!$this->ReadPropertyBoolean('Debug')) {
return;
}
if (!is_string($message)) {
$message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$this->SendDebug($title, (string) $message, 0);
}
}