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);
}
}
+82 -66
View File
@@ -1,58 +1,87 @@
# Shelly Modul
> Status: Implementiert. Die fachliche Funktion von
> `Shelly_Parser_MQTT` wurde für IP-Symcon 8.0 bereinigt und ohne
> EMS-Abhängigkeit in Enelix Utils adaptiert.
> Status: Implementiert. Der Parser verarbeitet Shelly-NG-Komponenten der
> Generationen 2, 3 und 4 generisch und unabhängig vom Gerätenamen.
Das Modul verarbeitet Shelly Gen2+-Statusmeldungen über das native
IP-Symcon-MQTT-Datenflussinterface. Ein interner MQTT Server wird beim
Erstellen als Standard-Parent angeboten; ein kompatibler MQTT Client kann
manuell als Gateway verbunden werden.
Das Modul verarbeitet Shelly-RPC- und Statusmeldungen über das native
IP-Symcon-MQTT-Datenflussinterface. Es gehört zu Enelix Utils und besitzt
keine Abhängigkeit zum Enelix EMS.
## Ziel und Abgrenzung
- automatische Erkennung von Geräten und vorhandenen Schaltkanälen
- Anzeige von Online-Status, Typ, Inputs, Outputs und Temperatur
- Schalten erkannter Outputs über Shelly RPC `Switch.Set`
- keine Geräteauswahl, Discovery-Instanz oder EMS-Kopplung
- keine Unterstützung für Shelly Gen1-`shellies/...`-Topics
- keine Verwaltung von MQTT-Zugangsdaten oder Shelly-Gerätekonfiguration
- freie MQTT-Client-ID und freie ein- oder mehrstufige Topic-Präfixe
- automatische Erkennung aller gemeldeten Komponenten und skalaren Werte
- vollständige Speicherung von Listen als JSON-String
- konfigurierbare Erzeugung nach Datenpunktgruppen
- weiterhin schaltbare `Switch.Set`-Ausgänge
- keine Verwaltung von Broker- oder Shelly-Zugangsdaten
- keine Shelly-Gen1-`shellies/...`-Topics
Die generische Verarbeitung ist absichtlich nicht an eine statische Liste von
Gerätemodellen gekoppelt. Neue Komponenten landen in der Gruppe `Other` und
können damit ohne Moduländerung eingelesen werden.
## MQTT-Datenfluss
| Richtung | Topic | Inhalt |
| --- | --- | --- |
| Shelly nach Symcon | `<Topic>/online` | `true`/`false`, `1`/`0` oder `online`/`offline` |
| Shelly nach Symcon | `<Topic>/events/rpc` | RPC-Payload mit `src` und `params` |
| Symcon nach Shelly | `<Topic>/rpc` | RPC-Aufruf `Switch.Set` |
| Shelly nach Symcon | `<Prefix>/online` | Online-Status |
| Shelly nach Symcon | `<Prefix>/events/rpc` | `NotifyStatus` oder `NotifyEvent` |
| 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
Topic-Ebene wird als Geräte-Topic verwendet. Der konfigurierte Präfix wird
ohne Beachtung der Gross-/Kleinschreibung geprüft.
## 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.
Der Gerätepräfix wird vom bekannten Topic-Ende her bestimmt. Dadurch sind
`symcon/events/rpc` und `gebaeude/etage/aktor/events/rpc` gleichermassen
gültig. Die MQTT-Client-ID ist nicht Bestandteil der Erkennungslogik.
## Properties
| Ident | Typ | Standard / Beschreibung |
| Ident | Standard | Beschreibung |
| --- | --- | --- |
| `DeviceTopicPrefix` | String | `shelly`; einfacher Präfix für die erste Topic-Ebene, leer akzeptiert alle gültigen Namen. |
| `Debug` | Boolean | `false`; Instanz-Debug für empfangene/gesendete MQTT-Daten und Validierungsfehler. |
| `UseDeviceTopicFilter` | `false` | Aktiviert den optionalen Präfixfilter. |
| `DeviceTopicPrefix` | `shelly` | Filterwert; ohne aktivierten Filter wirkungslos. |
| `CreateOnline` | `true` | Online-Status. |
| `CreateDeviceInfo` | `true` | Modell- und Geräteinformationen. |
| `CreateInputs` | `true` | Inputs und Eingangsereignisse. |
| `CreateSwitches` | `true` | Schaltausgänge und Schalterstatus. |
| `CreateCovers` | `true` | Cover- und Beschattungswerte. |
| `CreateLights` | `true` | Licht-, Dimm- und Farbwerte. |
| `CreatePower` | `true` | Wirk- und Scheinleistung. |
| `CreateVoltage` | `true` | Spannungswerte. |
| `CreateCurrent` | `true` | Stromwerte. |
| `CreateEnergy` | `true` | Bezogene und zurückgelieferte Energie. |
| `CreateFrequency` | `true` | Netzfrequenz. |
| `CreatePowerFactor` | `true` | Leistungsfaktor. |
| `CreateMeterDetails` | `true` | Weitere Zählerdaten. |
| `CreateTemperature` | `true` | Temperaturwerte. |
| `CreateHumidity` | `true` | Feuchtewerte. |
| `CreateIlluminance` | `true` | Helligkeit und Beleuchtungsstärke. |
| `CreateBattery` | `true` | Batterie- und Versorgungswerte. |
| `CreateEnvironment` | `true` | Weitere Umweltsensoren. |
| `CreateSystem` | `false` | System- und Netzwerkstatus. |
| `CreateOther` | `false` | Unbekannte und zukünftige Komponenten. |
| `Debug` | `false` | Diagnoseausgaben der Instanz. |
Ein deaktivierter Bereich wird weder neu angelegt noch aktualisiert. Eine
automatische Löschung bestehender Objekte findet nicht statt.
## Typabbildung
| Shelly-Wert | IP-Symcon-Typ |
| --- | --- |
| Boolean | Boolean |
| bekannte IDs, Revisionen und Zeitangaben | Integer |
| übrige numerische Messwerte | Float |
| String | String |
| Liste | JSON-String |
| `null` | wird ignoriert |
Verschachtelte Objekte werden rekursiv in stabile Pfade wie
`switch:0.aenergy.total` zerlegt. Technische Idents enthalten einen kurzen
Hash, damit Sonderzeichen, lange Pfade und ähnlich benannte Punkte nicht
kollidieren.
## Öffentliche Funktion
@@ -65,34 +94,21 @@ SHELLY_SetOutput(
): void;
```
Die Funktion validiert Topic-Präfix und Ausgangsindex. Ein fehlendes oder
inaktives MQTT-Gateway führt zu einer verständlichen Exception.
Die Funktion validiert das Topic und den Ausgangsindex. Ein fehlendes oder
inaktives MQTT-Gateway erzeugt eine verständliche Exception.
## Adaption aus Enelix 1
## Kompatibilität
| Teil | Entscheidung |
| --- | --- |
| MQTT-Datenfluss-GUIDs und RPC-`Switch.Set` | angepasst übernommen |
| dynamische Geräteordner und Datenpunkte | angepasst übernommen |
| Parser für Inputs, Outputs und Temperatur | neu und strenger implementiert |
| technische Idents mit roher Shelly-ID | verworfen; Bindestriche sind in IP-Symcon-Idents ungültig |
| gemeinsames Action-Skript | angepasst übernommen und verborgen |
| globale `#`-Subscription | verworfen; das native MQTT-Gateway liefert Publish-Daten über den Datenfluss |
| bedingungsloses `IPS_LogMessage` | verworfen; Debug-Ausgabe respektiert die Property |
| optimistisches Setzen eines Outputs | verworfen; Status folgt der Gerätebestätigung |
Die bestehenden Variablen für Online, Typ, Inputs, Switch-Ausgänge und die
Switch-Temperatur behalten ihre bisherigen Idents. Der frühere Präfixwert
`shelly` wirkt nach dem Update nur noch, wenn der neue Filter explizit
aktiviert wird.
## Betrieb und Diagnose
## Prüfung
1. MQTT-Gateway muss verbunden und aktiv sein.
2. Shelly MQTT muss RPC-Statusmeldungen publizieren.
3. Geräte-Topic und `DeviceTopicPrefix` müssen zusammenpassen.
4. Bei fehlenden Variablen kurzzeitig `Debug` aktivieren und eine
Statusänderung auslösen.
5. Zugangsdaten niemals in Logs oder Repository-Dokumentation übernehmen.
## Offene Punkte
- Praxistest mit den im Projekt eingesetzten Shelly-Modellen und deren
Firmwareständen.
- Bei Bedarf spätere Erweiterung um weitere Komponenten wie Cover, Light,
Meter oder Batteriestatus als separat beschlossene Funktionalität.
- Parser-Tests für freie und mehrstufige Topics
- Tests für unbekannte Modelle und generische Komponenten
- Tests für Gruppenklassifikation, Ereignisse und Typabbildung
- PHP-Syntaxprüfung und JSON-Prüfung
- Praxistest in IP-Symcon 8.0 mit projektseitigen Gen2-, Gen3- und
Gen4-Geräten bleibt nach dem Merge erforderlich
+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);
}
}
}