+76
-77
@@ -1,107 +1,106 @@
|
|||||||
# Shelly Modul
|
# Shelly Modul
|
||||||
|
|
||||||
Das Modul bindet Shelly-Geräte ab Generation 2 über deren MQTT/RPC-Protokoll
|
Das Modul bindet Shelly-NG-Geräte der Generationen 2, 3 und 4 über MQTT/RPC
|
||||||
in IP-Symcon ein. Es erkennt Geräte und Datenpunkte aus Statusmeldungen
|
in IP-Symcon ein. Es erkennt bekannte und zukünftige Komponenten dynamisch
|
||||||
automatisch und benötigt keine Abhängigkeit zum Enelix EMS.
|
und benötigt keine Abhängigkeit zum Enelix EMS.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- IP-Symcon ab Version 8.0
|
- IP-Symcon ab Version 8.0
|
||||||
- ein eingerichteter IP-Symcon MQTT Server oder MQTT Client
|
- ein eingerichteter IP-Symcon MQTT Server oder MQTT Client
|
||||||
- ein Shelly Gen2+-Gerät mit aktivierter MQTT-Verbindung
|
- ein Shelly Gen2+-Gerät mit aktivierter MQTT-Verbindung
|
||||||
- MQTT-RPC-Ereignisse auf `<Topic>/events/rpc` und der Online-Status auf
|
- aktivierte RPC-Statusmeldungen (`rpc_ntf`)
|
||||||
`<Topic>/online`
|
- optional aktivierte vollständige Statusmeldungen (`status_ntf`)
|
||||||
|
|
||||||
Shelly-Geräte der ersten Generation mit `shellies/...`-Topics werden nicht
|
Shelly-Geräte der ersten Generation mit `shellies/...`-Topics verwenden ein
|
||||||
unterstützt.
|
anderes Protokoll und werden von diesem Modul nicht verarbeitet.
|
||||||
|
|
||||||
## Einrichtung
|
## Freie MQTT-Namen
|
||||||
|
|
||||||
1. Das Modul über die Enelix-Utils-Bibliothek installieren und eine Instanz
|
MQTT-Client-ID, Brokername, Benutzername und Kennwort werden ausschliesslich
|
||||||
`Shelly Modul` anlegen.
|
im MQTT-Gateway beziehungsweise im Shelly konfiguriert. Das Modul wertet
|
||||||
2. Das gewünschte MQTT-Gateway verbinden. Neue Instanzen verwenden
|
diese Werte nicht aus. Der Shelly-`topic_prefix` darf frei gewählt werden und
|
||||||
standardmässig den internen MQTT Server; für einen externen Broker kann ein
|
auch mehrere Ebenen enthalten, zum Beispiel `symcon` oder
|
||||||
MQTT Client als Gateway gewählt werden.
|
`gebaeude/etage/aktor`.
|
||||||
3. Am Shelly MQTT aktivieren und den Broker, Port und gegebenenfalls
|
|
||||||
Zugangsdaten eintragen. Zugangsdaten gehören ausschliesslich in die
|
|
||||||
MQTT-/Shelly-Konfiguration und nicht in dieses Modul.
|
|
||||||
4. Den Topic-Präfix prüfen und die Instanz übernehmen.
|
|
||||||
5. Eine Statusänderung am Gerät auslösen. Der Geräteordner und seine Variablen
|
|
||||||
werden mit der ersten passenden Meldung angelegt.
|
|
||||||
|
|
||||||
## Konfiguration
|
Das Modul erkennt die Geräte-Topics anhand ihrer Endung:
|
||||||
|
|
||||||
| Property | Standard | Beschreibung |
|
| Topic | Verwendung |
|
||||||
| --- | --- | --- |
|
| --- | --- |
|
||||||
| `DeviceTopicPrefix` | `shelly` | Nur erste Topic-Ebenen mit diesem Präfix werden verarbeitet; Vergleich ohne Beachtung der Gross-/Kleinschreibung. Leer erlaubt alle gültigen Topic-Namen. |
|
| `<Prefix>/online` | Online-Status |
|
||||||
| `Debug` | `false` | Schreibt verarbeitete und gesendete MQTT-Nachrichten in den Instanz-Debug. |
|
| `<Prefix>/events/rpc` | `NotifyStatus` und `NotifyEvent` |
|
||||||
|
| `<Prefix>/status/<Komponente>` | vollständiger Komponentenstatus |
|
||||||
|
| `<Prefix>/status` | vollständiger Gerätestatus |
|
||||||
|
| `<Prefix>/announce` | Geräteinformationen |
|
||||||
|
|
||||||
Ein benutzerdefinierter Shelly-`topic_prefix` muss mit
|
Ein optionaler Topic-Filter kann in den Moduleinstellungen aktiviert werden.
|
||||||
`DeviceTopicPrefix` übereinstimmen. Das Feld ist ein einfacher Präfix und
|
Ohne aktivierten Filter werden alle gültigen Shelly-RPC-Topics verarbeitet.
|
||||||
kein regulärer Ausdruck.
|
|
||||||
|
|
||||||
## Automatisch angelegte Objekte
|
## Datenpunktauswahl
|
||||||
|
|
||||||
Für jede erste MQTT-Topic-Ebene wird ein Geräteordner angelegt. Technische
|
In der Konfiguration kann für jede Gruppe festgelegt werden, ob neue
|
||||||
Idents bestehen nur aus den von IP-Symcon erlaubten Zeichen und enthalten
|
Variablen angelegt und vorhandene Variablen aktualisiert werden:
|
||||||
zusätzlich einen kurzen Hash des originalen Topic-Namens. Damit bleiben auch
|
|
||||||
Namen mit Bindestrichen stabil und kollisionsarm.
|
|
||||||
|
|
||||||
| Anzeige | Typ / Zugriff | MQTT-Quelle |
|
- Online-Status und Geräteinformationen
|
||||||
| --- | --- | --- |
|
- Eingänge und Eingangsereignisse
|
||||||
| Online | Boolean / Anzeige | `<Topic>/online` |
|
- Schaltausgänge
|
||||||
| Typ | String / Anzeige | `src` einer RPC-Meldung |
|
- Rollladen und Beschattung
|
||||||
| Input n | Boolean / Anzeige | `input:n.state` oder `switch:n.input` |
|
- Licht, Dimmer und Farbe
|
||||||
| Output n | Boolean / bedienbar | `switch:n.output` |
|
- Leistung, Spannung, Strom, Energie, Frequenz und Leistungsfaktor einzeln
|
||||||
| Temperatur | Float / Anzeige | erster erkannter Celsiuswert in `temperature`/`tC` |
|
- Temperatur, Feuchte, Helligkeit und Batterie einzeln
|
||||||
|
- weitere Zähler- und Sensorwerte
|
||||||
|
- System- und Netzwerkdaten
|
||||||
|
- unbekannte und zukünftige Komponenten
|
||||||
|
|
||||||
Neue Inputs und Outputs werden dynamisch ergänzt. Bestehende Objekte werden
|
Systemdaten und unbekannte Datenpunkte sind standardmässig deaktiviert, damit
|
||||||
nicht automatisch gelöscht, wenn ein Datenpunkt später nicht mehr gemeldet
|
nicht unerwartet sehr viele Variablen entstehen. Bereits angelegte Variablen
|
||||||
wird.
|
werden beim Abwählen einer Gruppe nicht gelöscht.
|
||||||
|
|
||||||
|
## Dynamische Variablen
|
||||||
|
|
||||||
|
Boolesche Werte werden als Boolean, Zähler und Zeitangaben als Integer,
|
||||||
|
Messwerte als Float und Texte als String angelegt. Listen wie Fehlercodes oder
|
||||||
|
Minutenwerte werden vollständig als JSON-String gespeichert. `null` wird
|
||||||
|
ignoriert, da IP-Symcon keinen Null-Variablentyp besitzt.
|
||||||
|
|
||||||
|
Bekannte Datenpunkte erhalten lesbare Namen und passende Standardprofile,
|
||||||
|
sofern das Profil in IP-Symcon vorhanden ist. Unbekannte Felder werden über
|
||||||
|
ihren Komponenten- und Datenpfad stabil und kollisionsarm identifiziert.
|
||||||
|
|
||||||
## Ausgänge schalten
|
## Ausgänge schalten
|
||||||
|
|
||||||
Eine Bedienung von `Output n` veröffentlicht einen Shelly-RPC-Aufruf
|
Erkannte `switch:<n>.output`-Variablen bleiben bedienbar. Eine Bedienung
|
||||||
`Switch.Set` auf `<Topic>/rpc`. Der Variablenwert wird nicht optimistisch
|
veröffentlicht `Switch.Set` auf `<Prefix>/rpc`. Der Variablenwert wird erst
|
||||||
gesetzt; er folgt der nächsten Statusmeldung des Geräts. Dadurch zeigt
|
mit der nächsten Gerätemeldung aktualisiert.
|
||||||
IP-Symcon keinen erfolgreichen Schaltvorgang an, wenn der Broker oder das
|
|
||||||
Gerät den Auftrag nicht ausführt.
|
|
||||||
|
|
||||||
Aus Skripten kann derselbe Befehl direkt aufgerufen werden:
|
|
||||||
|
|
||||||
```php
|
```php
|
||||||
SHELLY_SetOutput($instanceID, 'shellyplus1pm-a1b2c3', 0, true);
|
SHELLY_SetOutput($instanceID, 'symcon', 0, true);
|
||||||
|
SHELLY_SetOutput($instanceID, 'gebaeude/etage/aktor', 1, false);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Unterstützte RPC-Struktur
|
Andere Komponenten werden derzeit vollständig eingelesen, aber nicht pauschal
|
||||||
|
schreibbar gemacht. Ihre RPC-Befehle unterscheiden sich fachlich, etwa bei
|
||||||
|
Cover-, Light-, RGB- oder Thermostat-Komponenten.
|
||||||
|
|
||||||
Das Modul verarbeitet Publish-Pakete (`PacketType = 3`) mit JSON-Payloads
|
## Einrichtung
|
||||||
nach folgendem Muster:
|
|
||||||
|
|
||||||
```json
|
1. Instanz `Shelly Modul` aus Enelix Utils anlegen.
|
||||||
{
|
2. Internen MQTT Server oder kompatiblen MQTT Client als Gateway verbinden.
|
||||||
"src": "shellyplus1pm-a1b2c3",
|
3. MQTT am Shelly aktivieren und RPC-Statusmeldungen einschalten.
|
||||||
"method": "NotifyStatus",
|
4. Gewünschte Datenpunktgruppen auswählen und Änderungen übernehmen.
|
||||||
"params": {
|
5. Am Gerät einen Statuswechsel auslösen.
|
||||||
"switch:0": {
|
|
||||||
"output": true,
|
|
||||||
"temperature": {"tC": 42.5}
|
|
||||||
},
|
|
||||||
"input:0": {"state": false}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Unvollständige, ungültige oder nicht zum konfigurierten Topic-Präfix passende
|
Bei einem externen MQTT-Broker muss der MQTT Client die passenden Topics
|
||||||
Nachrichten werden ignoriert. Bei aktiviertem Debug werden fehlerhafte
|
abonnieren. Zugangsdaten gehören niemals in die Modulkonfiguration.
|
||||||
JSON-Payloads und ignorierte Online-Werte nachvollziehbar protokolliert.
|
|
||||||
|
|
||||||
## Hinweise zur Adaption
|
## Migration
|
||||||
|
|
||||||
Aus `Shelly_Parser_MQTT` von Enelix 1 wurden MQTT/RPC-Datenfluss,
|
Der bisherige Wert `DeviceTopicPrefix = shelly` bleibt erhalten, wird aber nur
|
||||||
Geräteordner, dynamische Inputs/Outputs, Temperatur und `Switch.Set`
|
noch ausgewertet, wenn `UseDeviceTopicFilter` aktiviert ist. Bestehende
|
||||||
fachlich übernommen. Neu implementiert wurden gültige stabile Idents,
|
Online-, Typ-, Input-, Output- und Temperaturvariablen werden weiterverwendet.
|
||||||
tatsächlich schaltbares Debug-Logging, strikte Payload-Prüfung, ein
|
|
||||||
konfigurierbarer Topic-Präfix sowie verständliche Fehler bei einem inaktiven
|
## Diagnose
|
||||||
MQTT-Gateway. Die pauschale MQTT-`#`-Subscription und dauerhaftes
|
|
||||||
System-Logging wurden verworfen.
|
Mit `Debug` protokolliert die Instanz empfangene und gesendete MQTT-Pakete
|
||||||
|
sowie ungültige JSON-Nutzdaten. Für die Fehlersuche sollte Debug nur
|
||||||
|
vorübergehend aktiviert werden, weil Statusmeldungen umfangreich sein können.
|
||||||
|
|||||||
+34
-3
@@ -2,12 +2,43 @@
|
|||||||
"elements": [
|
"elements": [
|
||||||
{
|
{
|
||||||
"type": "Label",
|
"type": "Label",
|
||||||
"caption": "Verarbeitet MQTT/RPC-Statusmeldungen von Shelly Gen2+ und legt erkannte Geräte und Datenpunkte automatisch an."
|
"caption": "Verarbeitet Shelly Gen2+-Daten unabhängig von MQTT-Client-ID und frei wählbarem Topic-Präfix."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "CheckBox",
|
||||||
|
"name": "UseDeviceTopicFilter",
|
||||||
|
"caption": "Nur Geräte-Topics mit folgendem Präfix verarbeiten"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "ValidationTextBox",
|
"type": "ValidationTextBox",
|
||||||
"name": "DeviceTopicPrefix",
|
"name": "DeviceTopicPrefix",
|
||||||
"caption": "Erlaubter MQTT-Topic-Präfix"
|
"caption": "Optionaler Geräte-Topic-Filter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ExpansionPanel",
|
||||||
|
"caption": "Zu erzeugende Datenpunktgruppen",
|
||||||
|
"items": [
|
||||||
|
{"type": "CheckBox", "name": "CreateOnline", "caption": "Online-Status"},
|
||||||
|
{"type": "CheckBox", "name": "CreateDeviceInfo", "caption": "Geräteinformationen"},
|
||||||
|
{"type": "CheckBox", "name": "CreateInputs", "caption": "Eingänge und Eingangsereignisse"},
|
||||||
|
{"type": "CheckBox", "name": "CreateSwitches", "caption": "Schaltausgänge"},
|
||||||
|
{"type": "CheckBox", "name": "CreateCovers", "caption": "Rollladen und Beschattung"},
|
||||||
|
{"type": "CheckBox", "name": "CreateLights", "caption": "Licht, Dimmer und Farbe"},
|
||||||
|
{"type": "CheckBox", "name": "CreatePower", "caption": "Leistung"},
|
||||||
|
{"type": "CheckBox", "name": "CreateVoltage", "caption": "Spannung"},
|
||||||
|
{"type": "CheckBox", "name": "CreateCurrent", "caption": "Strom"},
|
||||||
|
{"type": "CheckBox", "name": "CreateEnergy", "caption": "Energiezähler"},
|
||||||
|
{"type": "CheckBox", "name": "CreateFrequency", "caption": "Frequenz"},
|
||||||
|
{"type": "CheckBox", "name": "CreatePowerFactor", "caption": "Leistungsfaktor"},
|
||||||
|
{"type": "CheckBox", "name": "CreateMeterDetails", "caption": "Weitere Zählerdaten"},
|
||||||
|
{"type": "CheckBox", "name": "CreateTemperature", "caption": "Temperatur"},
|
||||||
|
{"type": "CheckBox", "name": "CreateHumidity", "caption": "Luftfeuchtigkeit"},
|
||||||
|
{"type": "CheckBox", "name": "CreateIlluminance", "caption": "Helligkeit und Beleuchtungsstärke"},
|
||||||
|
{"type": "CheckBox", "name": "CreateBattery", "caption": "Batterie und Versorgung"},
|
||||||
|
{"type": "CheckBox", "name": "CreateEnvironment", "caption": "Weitere Sensorwerte"},
|
||||||
|
{"type": "CheckBox", "name": "CreateSystem", "caption": "System- und Netzwerkdaten"},
|
||||||
|
{"type": "CheckBox", "name": "CreateOther", "caption": "Unbekannte und zukünftige Datenpunkte"}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "CheckBox",
|
"type": "CheckBox",
|
||||||
@@ -18,7 +49,7 @@
|
|||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"type": "Label",
|
"type": "Label",
|
||||||
"caption": "MQTT wird am Shelly-Gerät konfiguriert. Standardmässig wird der interne MQTT Server verwendet; ein vorhandener MQTT Client kann als Gateway gewählt werden."
|
"caption": "Nicht gewählte Gruppen werden nicht neu angelegt oder aktualisiert. Bereits vorhandene Variablen werden nicht gelöscht."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,38 @@ declare(strict_types=1);
|
|||||||
final class ShellyParser
|
final class ShellyParser
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Extrahiert den Modellteil aus einer Shelly-RPC-Source.
|
* @return null|array{deviceTopic: string, kind: string, component: ?string}
|
||||||
*/
|
*/
|
||||||
|
public static function parseTopic(string $topic): ?array
|
||||||
|
{
|
||||||
|
$topic = trim($topic, '/');
|
||||||
|
if ($topic === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns = [
|
||||||
|
'rpc' => '#^(.+)/events/rpc$#',
|
||||||
|
'component_status' => '#^(.+)/status/([^/]+)$#',
|
||||||
|
'status' => '#^(.+)/status$#',
|
||||||
|
'online' => '#^(.+)/online$#',
|
||||||
|
'announce' => '#^(.+)/announce$#',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($patterns as $kind => $pattern) {
|
||||||
|
if (preg_match($pattern, $topic, $matches) !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'deviceTopic' => $matches[1],
|
||||||
|
'kind' => $kind,
|
||||||
|
'component' => $matches[2] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public static function extractType(string $source): string
|
public static function extractType(string $source): string
|
||||||
{
|
{
|
||||||
if (preg_match('/^shelly([a-z0-9]+)-/i', trim($source), $matches) !== 1) {
|
if (preg_match('/^shelly([a-z0-9]+)-/i', trim($source), $matches) !== 1) {
|
||||||
@@ -17,120 +47,226 @@ final class ShellyParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{outputs: array<int, bool>, inputs: array<int, bool>, temperature: ?float}
|
* @return array<int, array{component: string, path: string, value: bool|int|float|string}>
|
||||||
*/
|
*/
|
||||||
public static function mapParams(array $params): array
|
public static function pointsFromRpc(array $rpc): array
|
||||||
{
|
{
|
||||||
$mapped = [
|
$method = $rpc['method'] ?? null;
|
||||||
'outputs' => [],
|
$params = $rpc['params'] ?? null;
|
||||||
'inputs' => [],
|
if (!is_string($method) || !is_array($params)) {
|
||||||
'temperature' => null,
|
return [];
|
||||||
];
|
}
|
||||||
|
|
||||||
foreach ($params as $component => $value) {
|
if ($method === 'NotifyStatus') {
|
||||||
|
return self::pointsFromStatus($params);
|
||||||
|
}
|
||||||
|
if ($method === 'NotifyEvent') {
|
||||||
|
return self::pointsFromEvents($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{component: string, path: string, value: bool|int|float|string}>
|
||||||
|
*/
|
||||||
|
public static function pointsFromStatus(array $status): array
|
||||||
|
{
|
||||||
|
$points = [];
|
||||||
|
foreach ($status as $component => $value) {
|
||||||
|
if ($component === 'ts' && (is_int($value) || is_float($value))) {
|
||||||
|
$points[] = self::point('meta', 'ts', $value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!is_string($component) || !is_array($value)) {
|
if (!is_string($component) || !is_array($value)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('/^switch:(\d+)$/', $component, $matches) === 1) {
|
self::flatten($component, $value, '', $points);
|
||||||
$index = (int) $matches[1];
|
|
||||||
$output = self::toBoolean($value['output'] ?? null);
|
|
||||||
$input = self::toBoolean($value['input'] ?? null);
|
|
||||||
|
|
||||||
if ($output !== null) {
|
|
||||||
$mapped['outputs'][$index] = $output;
|
|
||||||
}
|
|
||||||
if ($input !== null) {
|
|
||||||
$mapped['inputs'][$index] = $input;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/^input:(\d+)$/', $component, $matches) === 1) {
|
|
||||||
$state = self::toBoolean($value['state'] ?? null);
|
|
||||||
if ($state !== null) {
|
|
||||||
$mapped['inputs'][(int) $matches[1]] = $state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$mapped['temperature'] = self::findTemperature($params);
|
return $points;
|
||||||
ksort($mapped['outputs']);
|
|
||||||
ksort($mapped['inputs']);
|
|
||||||
|
|
||||||
return $mapped;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function toBoolean($value): ?bool
|
/**
|
||||||
|
* @return array<int, array{component: string, path: string, value: bool|int|float|string}>
|
||||||
|
*/
|
||||||
|
public static function pointsFromComponent(string $component, array $status): array
|
||||||
{
|
{
|
||||||
if (is_bool($value)) {
|
$points = [];
|
||||||
return $value;
|
self::flatten($component, $status, '', $points);
|
||||||
}
|
|
||||||
|
|
||||||
if ($value === 1 || $value === '1') {
|
return $points;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ($value === 0 || $value === '0') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($value)) {
|
|
||||||
$normalized = strtolower(trim($value));
|
|
||||||
if (in_array($normalized, ['true', 'on', 'online'], true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (in_array($normalized, ['false', 'off', 'offline'], true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function findTemperature(array $data): ?float
|
/**
|
||||||
|
* @return array<int, array{component: string, path: string, value: bool|int|float|string}>
|
||||||
|
*/
|
||||||
|
public static function pointsFromAnnounce(array $announce): array
|
||||||
|
{
|
||||||
|
$points = [];
|
||||||
|
self::flatten('device', $announce, '', $points);
|
||||||
|
|
||||||
|
return $points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{component: string, path: string, value: bool|int|float|string}>
|
||||||
|
*/
|
||||||
|
private static function pointsFromEvents(array $params): array
|
||||||
|
{
|
||||||
|
$points = [];
|
||||||
|
$events = $params['events'] ?? [];
|
||||||
|
if (!is_array($events)) {
|
||||||
|
return $points;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($events as $event) {
|
||||||
|
if (!is_array($event)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$component = $event['component'] ?? 'event';
|
||||||
|
if (!is_string($component) || $component === '') {
|
||||||
|
$component = 'event';
|
||||||
|
}
|
||||||
|
unset($event['component']);
|
||||||
|
self::flatten($component, $event, 'event', $points);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{component: string, path: string, value: bool|int|float|string}> $points
|
||||||
|
*/
|
||||||
|
private static function flatten(string $component, array $data, string $prefix, array &$points): void
|
||||||
{
|
{
|
||||||
foreach ($data as $key => $value) {
|
foreach ($data as $key => $value) {
|
||||||
if (!is_string($key)) {
|
$key = (string) $key;
|
||||||
|
$path = $prefix === '' ? $key : $prefix . '.' . $key;
|
||||||
|
if ($value === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$lowerKey = strtolower($key);
|
|
||||||
if ($lowerKey === 'temperature') {
|
|
||||||
$temperature = self::temperatureFromValue($value);
|
|
||||||
if ($temperature !== null) {
|
|
||||||
return $temperature;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lowerKey === 'tc' && is_numeric($value)) {
|
|
||||||
return (float) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
$temperature = self::findTemperature($value);
|
if (self::isList($value)) {
|
||||||
if ($temperature !== null) {
|
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
return $temperature;
|
if ($json !== false) {
|
||||||
|
$points[] = self::point($component, $path, $json);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self::flatten($component, $value, $path, $points);
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) {
|
||||||
|
$points[] = self::point($component, $path, $value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function temperatureFromValue($value): ?float
|
/**
|
||||||
|
* @param bool|int|float|string $value
|
||||||
|
* @return array{component: string, path: string, value: bool|int|float|string}
|
||||||
|
*/
|
||||||
|
private static function point(string $component, string $path, $value): array
|
||||||
{
|
{
|
||||||
if (is_numeric($value)) {
|
return ['component' => $component, 'path' => $path, 'value' => $value];
|
||||||
return (float) $value;
|
}
|
||||||
}
|
|
||||||
if (!is_array($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['tC', 'tc', 't'] as $key) {
|
private static function isList(array $value): bool
|
||||||
if (array_key_exists($key, $value) && is_numeric($value[$key])) {
|
{
|
||||||
return (float) $value[$key];
|
$expectedKey = 0;
|
||||||
|
foreach ($value as $key => $_) {
|
||||||
|
if ($key !== $expectedKey) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
++$expectedKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function groupForPoint(string $component, string $path): string
|
||||||
|
{
|
||||||
|
$componentType = strtolower(explode(':', $component, 2)[0]);
|
||||||
|
$path = strtolower($path);
|
||||||
|
|
||||||
|
if (in_array($componentType, ['meta', 'device'], true)) {
|
||||||
|
return 'device';
|
||||||
|
}
|
||||||
|
if ($componentType === 'input') {
|
||||||
|
return 'inputs';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(aenergy|ret_aenergy|energy|[a-z_]+_energy|total_act|total_act_ret)(\.|$)/', $path) === 1) {
|
||||||
|
return 'energy';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(apower|aprtpower|power|[a-z_]+_power)(\.|$)/', $path) === 1) {
|
||||||
|
return 'power';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(voltage|[a-z]_voltage)(\.|$)/', $path) === 1) {
|
||||||
|
return 'voltage';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(current|[a-z]_current)(\.|$)/', $path) === 1) {
|
||||||
|
return 'current';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(freq|[a-z]_freq)(\.|$)/', $path) === 1) {
|
||||||
|
return 'frequency';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(pf|[a-z]_pf)(\.|$)/', $path) === 1) {
|
||||||
|
return 'power_factor';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(temperature|tc|tf)(\.|$)/', $path) === 1 || $componentType === 'temperature') {
|
||||||
|
return 'temperature';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(humidity|rh)(\.|$)/', $path) === 1 || $componentType === 'humidity') {
|
||||||
|
return 'humidity';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(illuminance|lux)(\.|$)/', $path) === 1 || $componentType === 'illuminance') {
|
||||||
|
return 'illuminance';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(battery)(\.|$)/', $path) === 1 || $componentType === 'devicepower') {
|
||||||
|
return 'battery';
|
||||||
|
}
|
||||||
|
if ($componentType === 'switch') {
|
||||||
|
return 'switches';
|
||||||
|
}
|
||||||
|
if ($componentType === 'cover') {
|
||||||
|
return 'covers';
|
||||||
|
}
|
||||||
|
if (in_array($componentType, ['light', 'rgb', 'rgbw', 'cct', 'rgbcct'], true)) {
|
||||||
|
return 'lights';
|
||||||
|
}
|
||||||
|
if (in_array($componentType, ['pm1', 'em', 'em1', 'emdata'], true)) {
|
||||||
|
return 'meters';
|
||||||
|
}
|
||||||
|
if (in_array($componentType, ['smoke', 'flood', 'gas'], true)) {
|
||||||
|
return 'environment';
|
||||||
|
}
|
||||||
|
if (in_array($componentType, ['sys', 'wifi', 'eth', 'mqtt', 'cloud', 'ws', 'ble', 'modbus'], true)) {
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool|int|float|string $value
|
||||||
|
*/
|
||||||
|
public static function variableType($value, string $path): int
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (is_string($value)) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if ((is_int($value) || is_float($value))
|
||||||
|
&& preg_match('/(^|\.)(id|ts|uptime|unixtime|[a-z_]+_ts|[a-z_]+_rev|rssi)$/i', $path) === 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+233
-78
@@ -9,11 +9,54 @@ class ShellyModul extends IPSModule
|
|||||||
private const MQTT_SERVER_MODULE_ID = '{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}';
|
private const MQTT_SERVER_MODULE_ID = '{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}';
|
||||||
private const MQTT_TX_DATA_ID = '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}';
|
private const MQTT_TX_DATA_ID = '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}';
|
||||||
|
|
||||||
|
private const GROUP_PROPERTIES = [
|
||||||
|
'device' => 'CreateDeviceInfo',
|
||||||
|
'inputs' => 'CreateInputs',
|
||||||
|
'switches' => 'CreateSwitches',
|
||||||
|
'covers' => 'CreateCovers',
|
||||||
|
'lights' => 'CreateLights',
|
||||||
|
'power' => 'CreatePower',
|
||||||
|
'voltage' => 'CreateVoltage',
|
||||||
|
'current' => 'CreateCurrent',
|
||||||
|
'energy' => 'CreateEnergy',
|
||||||
|
'frequency' => 'CreateFrequency',
|
||||||
|
'power_factor' => 'CreatePowerFactor',
|
||||||
|
'meters' => 'CreateMeterDetails',
|
||||||
|
'temperature' => 'CreateTemperature',
|
||||||
|
'humidity' => 'CreateHumidity',
|
||||||
|
'illuminance' => 'CreateIlluminance',
|
||||||
|
'battery' => 'CreateBattery',
|
||||||
|
'environment' => 'CreateEnvironment',
|
||||||
|
'system' => 'CreateSystem',
|
||||||
|
'other' => 'CreateOther',
|
||||||
|
];
|
||||||
|
|
||||||
public function Create()
|
public function Create()
|
||||||
{
|
{
|
||||||
parent::Create();
|
parent::Create();
|
||||||
|
|
||||||
|
$this->RegisterPropertyBoolean('UseDeviceTopicFilter', false);
|
||||||
$this->RegisterPropertyString('DeviceTopicPrefix', 'shelly');
|
$this->RegisterPropertyString('DeviceTopicPrefix', 'shelly');
|
||||||
|
$this->RegisterPropertyBoolean('CreateOnline', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateDeviceInfo', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateInputs', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateSwitches', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateCovers', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateLights', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreatePower', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateVoltage', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateCurrent', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateEnergy', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateFrequency', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreatePowerFactor', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateMeterDetails', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateTemperature', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateHumidity', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateIlluminance', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateBattery', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateEnvironment', true);
|
||||||
|
$this->RegisterPropertyBoolean('CreateSystem', false);
|
||||||
|
$this->RegisterPropertyBoolean('CreateOther', false);
|
||||||
$this->RegisterPropertyBoolean('Debug', false);
|
$this->RegisterPropertyBoolean('Debug', false);
|
||||||
$this->ConnectParent(self::MQTT_SERVER_MODULE_ID);
|
$this->ConnectParent(self::MQTT_SERVER_MODULE_ID);
|
||||||
}
|
}
|
||||||
@@ -42,25 +85,32 @@ class ShellyModul extends IPSModule
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$topicParts = explode('/', $topic);
|
$topicInfo = ShellyParser::parseTopic($topic);
|
||||||
if (count($topicParts) < 2) {
|
if ($topicInfo === null || !$this->isAcceptedDeviceTopic($topicInfo['deviceTopic'])) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$deviceTopic = array_shift($topicParts);
|
|
||||||
if (!$this->isAcceptedDeviceTopic($deviceTopic)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->debug('MQTT empfangen', $topic . ' -> ' . $payload);
|
$this->debug('MQTT empfangen', $topic . ' -> ' . $payload);
|
||||||
|
$deviceTopic = $topicInfo['deviceTopic'];
|
||||||
|
|
||||||
if ($topicParts === ['online']) {
|
if ($topicInfo['kind'] === 'online') {
|
||||||
$this->handleOnline($deviceTopic, $payload);
|
$this->handleOnline($deviceTopic, $payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($topicParts === ['events', 'rpc']) {
|
$decoded = $this->decodePayload($payload);
|
||||||
$this->handleRpc($deviceTopic, $payload);
|
if ($decoded === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($topicInfo['kind'] === 'rpc') {
|
||||||
|
$this->handleRpc($deviceTopic, $decoded);
|
||||||
|
} elseif ($topicInfo['kind'] === 'component_status' && $topicInfo['component'] !== null) {
|
||||||
|
$this->updatePoints($deviceTopic, ShellyParser::pointsFromComponent($topicInfo['component'], $decoded));
|
||||||
|
} elseif ($topicInfo['kind'] === 'status') {
|
||||||
|
$this->updatePoints($deviceTopic, ShellyParser::pointsFromStatus($decoded));
|
||||||
|
} elseif ($topicInfo['kind'] === 'announce') {
|
||||||
|
$this->updatePoints($deviceTopic, ShellyParser::pointsFromAnnounce($decoded));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +126,6 @@ class ShellyModul extends IPSModule
|
|||||||
if (!IPS_ObjectExists($folderID) || IPS_GetParent($folderID) !== $this->InstanceID) {
|
if (!IPS_ObjectExists($folderID) || IPS_GetParent($folderID) !== $this->InstanceID) {
|
||||||
throw new InvalidArgumentException('Der Geräteordner gehört nicht zu dieser Instanz.');
|
throw new InvalidArgumentException('Der Geräteordner gehört nicht zu dieser Instanz.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('/_output_(\d+)$/', $variableIdent, $matches) !== 1) {
|
if (preg_match('/_output_(\d+)$/', $variableIdent, $matches) !== 1) {
|
||||||
throw new InvalidArgumentException('Unbekannter Shelly-Ausgang: ' . $variableIdent);
|
throw new InvalidArgumentException('Unbekannter Shelly-Ausgang: ' . $variableIdent);
|
||||||
}
|
}
|
||||||
@@ -92,7 +141,7 @@ class ShellyModul extends IPSModule
|
|||||||
public function SetOutput(string $DeviceTopic, int $Output, bool $Value): void
|
public function SetOutput(string $DeviceTopic, int $Output, bool $Value): void
|
||||||
{
|
{
|
||||||
if (!$this->isAcceptedDeviceTopic($DeviceTopic)) {
|
if (!$this->isAcceptedDeviceTopic($DeviceTopic)) {
|
||||||
throw new InvalidArgumentException('Das Geräte-Topic entspricht nicht dem konfigurierten Präfix.');
|
throw new InvalidArgumentException('Das Geräte-Topic ist ungültig oder entspricht nicht dem aktiven Filter.');
|
||||||
}
|
}
|
||||||
if ($Output < 0) {
|
if ($Output < 0) {
|
||||||
throw new InvalidArgumentException('Der Ausgangsindex darf nicht negativ sein.');
|
throw new InvalidArgumentException('Der Ausgangsindex darf nicht negativ sein.');
|
||||||
@@ -102,18 +151,31 @@ class ShellyModul extends IPSModule
|
|||||||
'id' => random_int(1, 2147483647),
|
'id' => random_int(1, 2147483647),
|
||||||
'src' => 'enelix-symcon-' . $this->InstanceID,
|
'src' => 'enelix-symcon-' . $this->InstanceID,
|
||||||
'method' => 'Switch.Set',
|
'method' => 'Switch.Set',
|
||||||
'params' => [
|
'params' => ['id' => $Output, 'on' => $Value],
|
||||||
'id' => $Output,
|
|
||||||
'on' => $Value,
|
|
||||||
],
|
|
||||||
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
$this->publish($DeviceTopic . '/rpc', $payload);
|
$this->publish($DeviceTopic . '/rpc', $payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function decodePayload(string $payload): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (JsonException $exception) {
|
||||||
|
$this->debug('Ungültige Shelly-Nutzdaten', $exception->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function handleOnline(string $deviceTopic, string $payload): void
|
private function handleOnline(string $deviceTopic, string $payload): void
|
||||||
{
|
{
|
||||||
$value = ShellyParser::toBoolean($payload);
|
if (!$this->ReadPropertyBoolean('CreateOnline')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $this->toBoolean($payload);
|
||||||
if ($value === null) {
|
if ($value === null) {
|
||||||
$this->debug('Online-Status ignoriert', $payload);
|
$this->debug('Online-Status ignoriert', $payload);
|
||||||
return;
|
return;
|
||||||
@@ -125,77 +187,158 @@ class ShellyModul extends IPSModule
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleRpc(string $deviceTopic, string $payload): void
|
private function handleRpc(string $deviceTopic, array $rpc): void
|
||||||
{
|
{
|
||||||
try {
|
|
||||||
$rpc = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
} catch (JsonException $exception) {
|
|
||||||
$this->debug('Ungültiges Shelly-RPC', $exception->getMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_array($rpc)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$source = $rpc['src'] ?? '';
|
$source = $rpc['src'] ?? '';
|
||||||
if (!is_string($source) || ShellyParser::extractType($source) === 'unknown') {
|
if ($this->ReadPropertyBoolean('CreateDeviceInfo') && is_string($source)) {
|
||||||
return;
|
$type = ShellyParser::extractType($source);
|
||||||
|
if ($type !== 'unknown') {
|
||||||
|
$typeID = $this->ensureVariable($deviceTopic, 'type', 'Typ', 3, '', 20);
|
||||||
|
if ($typeID > 0) {
|
||||||
|
SetValue($typeID, $type);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$params = $rpc['params'] ?? null;
|
$this->updatePoints($deviceTopic, ShellyParser::pointsFromRpc($rpc));
|
||||||
if (!is_array($params)) {
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$typeID = $this->ensureVariable($deviceTopic, 'type', 'Typ', 3, '', 20);
|
/**
|
||||||
if ($typeID > 0) {
|
* @param array<int, array{component: string, path: string, value: bool|int|float|string}> $points
|
||||||
SetValue($typeID, ShellyParser::extractType($source));
|
*/
|
||||||
}
|
private function updatePoints(string $deviceTopic, array $points): void
|
||||||
|
{
|
||||||
|
foreach ($points as $point) {
|
||||||
|
$group = ShellyParser::groupForPoint($point['component'], $point['path']);
|
||||||
|
if (!$this->isGroupEnabled($group)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$mapped = ShellyParser::mapParams($params);
|
$definition = $this->pointDefinition($deviceTopic, $point);
|
||||||
foreach ($mapped['inputs'] as $index => $value) {
|
|
||||||
$variableID = $this->ensureVariable(
|
$variableID = $this->ensureVariable(
|
||||||
$deviceTopic,
|
$deviceTopic,
|
||||||
'input_' . $index,
|
$definition['suffix'],
|
||||||
'Input ' . $index,
|
$definition['name'],
|
||||||
0,
|
$definition['type'],
|
||||||
'',
|
$definition['profile'],
|
||||||
100 + $index
|
$definition['position'],
|
||||||
|
$definition['action']
|
||||||
);
|
);
|
||||||
if ($variableID > 0) {
|
if ($variableID > 0) {
|
||||||
|
$value = $point['value'];
|
||||||
|
if ($definition['type'] === 1 && is_float($value)) {
|
||||||
|
$value = (int) $value;
|
||||||
|
} elseif ($definition['type'] === 2 && is_int($value)) {
|
||||||
|
$value = (float) $value;
|
||||||
|
}
|
||||||
SetValue($variableID, $value);
|
SetValue($variableID, $value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($mapped['outputs'] as $index => $value) {
|
private function isGroupEnabled(string $group): bool
|
||||||
$variableID = $this->ensureVariable(
|
{
|
||||||
$deviceTopic,
|
$property = self::GROUP_PROPERTIES[$group] ?? 'CreateOther';
|
||||||
'output_' . $index,
|
return $this->ReadPropertyBoolean($property);
|
||||||
'Output ' . $index,
|
}
|
||||||
0,
|
|
||||||
'~Switch',
|
/**
|
||||||
200 + $index,
|
* @param array{component: string, path: string, value: bool|int|float|string} $point
|
||||||
true
|
* @return array{suffix: string, name: string, type: int, profile: string, position: int, action: bool}
|
||||||
);
|
*/
|
||||||
if ($variableID > 0) {
|
private function pointDefinition(string $deviceTopic, array $point): array
|
||||||
SetValue($variableID, $value);
|
{
|
||||||
}
|
[$componentType, $componentIndex] = array_pad(explode(':', $point['component'], 2), 2, null);
|
||||||
|
if ($componentType === 'switch' && $point['path'] === 'output' && ctype_digit((string) $componentIndex)) {
|
||||||
|
$index = (int) $componentIndex;
|
||||||
|
return [
|
||||||
|
'suffix' => 'output_' . $index,
|
||||||
|
'name' => 'Output ' . $index,
|
||||||
|
'type' => 0,
|
||||||
|
'profile' => '~Switch',
|
||||||
|
'position' => 200 + $index,
|
||||||
|
'action' => true,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($mapped['temperature'] !== null) {
|
if (($componentType === 'input' && $point['path'] === 'state')
|
||||||
$variableID = $this->ensureVariable(
|
|| ($componentType === 'switch' && $point['path'] === 'input')) {
|
||||||
$deviceTopic,
|
$index = ctype_digit((string) $componentIndex) ? (int) $componentIndex : 0;
|
||||||
'temperature',
|
return [
|
||||||
'Temperatur',
|
'suffix' => 'input_' . $index,
|
||||||
2,
|
'name' => 'Input ' . $index,
|
||||||
'~Temperature',
|
'type' => 0,
|
||||||
300
|
'profile' => '',
|
||||||
);
|
'position' => 100 + $index,
|
||||||
if ($variableID > 0) {
|
'action' => false,
|
||||||
SetValue($variableID, $mapped['temperature']);
|
];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($componentType === 'switch' && str_ends_with(strtolower($point['path']), 'temperature.tc')) {
|
||||||
|
return [
|
||||||
|
'suffix' => 'temperature',
|
||||||
|
'name' => 'Temperatur',
|
||||||
|
'type' => 2,
|
||||||
|
'profile' => '~Temperature',
|
||||||
|
'position' => 300,
|
||||||
|
'action' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $point['component'] . '.' . $point['path'];
|
||||||
|
$normalized = strtolower($source);
|
||||||
|
$normalized = preg_replace('/[^a-z0-9_]+/', '_', $normalized) ?? 'value';
|
||||||
|
$normalized = trim($normalized, '_');
|
||||||
|
$suffix = 'dp_' . substr($normalized, 0, 48) . '_' . substr(hash('sha256', $source), 0, 8);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'suffix' => $suffix,
|
||||||
|
'name' => $this->displayName($point['component'], $point['path']),
|
||||||
|
'type' => ShellyParser::variableType($point['value'], $point['path']),
|
||||||
|
'profile' => $this->profileForPoint($point['component'], $point['path']),
|
||||||
|
'position' => 1000 + (int) (hexdec(substr(hash('sha256', $source), 0, 4)) % 50000),
|
||||||
|
'action' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function displayName(string $component, string $path): string
|
||||||
|
{
|
||||||
|
$fieldNames = [
|
||||||
|
'apower' => 'Wirkleistung',
|
||||||
|
'aprtpower' => 'Scheinleistung',
|
||||||
|
'voltage' => 'Spannung',
|
||||||
|
'current' => 'Strom',
|
||||||
|
'freq' => 'Frequenz',
|
||||||
|
'pf' => 'Leistungsfaktor',
|
||||||
|
'total' => 'Gesamt',
|
||||||
|
'rh' => 'Luftfeuchtigkeit',
|
||||||
|
'tc' => 'Temperatur',
|
||||||
|
'current_pos' => 'Position',
|
||||||
|
'brightness' => 'Helligkeit',
|
||||||
|
];
|
||||||
|
|
||||||
|
$componentLabel = ucfirst(str_replace(':', ' ', $component));
|
||||||
|
$parts = explode('.', $path);
|
||||||
|
foreach ($parts as &$part) {
|
||||||
|
$lower = strtolower($part);
|
||||||
|
$part = $fieldNames[$lower] ?? ucfirst(str_replace('_', ' ', $part));
|
||||||
|
}
|
||||||
|
unset($part);
|
||||||
|
|
||||||
|
return $componentLabel . ' - ' . implode(' / ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function profileForPoint(string $component, string $path): string
|
||||||
|
{
|
||||||
|
$fullPath = strtolower($component . '.' . $path);
|
||||||
|
if (preg_match('/(^|\.)(temperature|tc)(\.|$)/', $fullPath) === 1) {
|
||||||
|
return '~Temperature';
|
||||||
|
}
|
||||||
|
if (preg_match('/(^|\.)(humidity|rh)(\.|$)/', $fullPath) === 1) {
|
||||||
|
return '~Humidity';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ensureVariable(
|
private function ensureVariable(
|
||||||
@@ -224,7 +367,7 @@ class ShellyModul extends IPSModule
|
|||||||
|
|
||||||
IPS_SetName($variableID, $name);
|
IPS_SetName($variableID, $name);
|
||||||
IPS_SetPosition($variableID, $position);
|
IPS_SetPosition($variableID, $position);
|
||||||
if ($profile !== '') {
|
if ($profile !== '' && IPS_VariableProfileExists($profile)) {
|
||||||
IPS_SetVariableCustomProfile($variableID, $profile);
|
IPS_SetVariableCustomProfile($variableID, $profile);
|
||||||
}
|
}
|
||||||
if ($action) {
|
if ($action) {
|
||||||
@@ -258,7 +401,6 @@ class ShellyModul extends IPSModule
|
|||||||
{
|
{
|
||||||
$ident = 'action_handler';
|
$ident = 'action_handler';
|
||||||
$scriptID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
$scriptID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
||||||
|
|
||||||
if ($scriptID === false) {
|
if ($scriptID === false) {
|
||||||
$scriptID = IPS_CreateScript(0);
|
$scriptID = IPS_CreateScript(0);
|
||||||
IPS_SetParent($scriptID, $this->InstanceID);
|
IPS_SetParent($scriptID, $this->InstanceID);
|
||||||
@@ -310,14 +452,14 @@ PHP
|
|||||||
|
|
||||||
private function isAcceptedDeviceTopic(string $deviceTopic): bool
|
private function isAcceptedDeviceTopic(string $deviceTopic): bool
|
||||||
{
|
{
|
||||||
if ($deviceTopic === '' || strlen($deviceTopic) > 128) {
|
if ($deviceTopic === '' || strlen($deviceTopic) > 300 || preg_match('//u', $deviceTopic) !== 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (preg_match('//u', $deviceTopic) !== 1) {
|
if (preg_match('/[\x00-\x1F\x7F#\+]/u', $deviceTopic) === 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (preg_match('/[\x00-\x1F\x7F\/#\+]/u', $deviceTopic) === 1) {
|
if (!$this->ReadPropertyBoolean('UseDeviceTopicFilter')) {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$prefix = trim($this->ReadPropertyString('DeviceTopicPrefix'));
|
$prefix = trim($this->ReadPropertyString('DeviceTopicPrefix'));
|
||||||
@@ -336,15 +478,28 @@ PHP
|
|||||||
return substr($normalized, 0, 48) . '_' . substr(hash('sha256', $deviceTopic), 0, 8);
|
return substr($normalized, 0, 48) . '_' . substr(hash('sha256', $deviceTopic), 0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function toBoolean(string $value): ?bool
|
||||||
|
{
|
||||||
|
$normalized = strtolower(trim($value));
|
||||||
|
if (in_array($normalized, ['true', '1', 'on', 'online'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (in_array($normalized, ['false', '0', 'off', 'offline'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function debug(string $title, $message): void
|
private function debug(string $title, $message): void
|
||||||
{
|
{
|
||||||
if (!$this->ReadPropertyBoolean('Debug')) {
|
if (!$this->ReadPropertyBoolean('Debug')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_string($message)) {
|
if (!is_string($message)) {
|
||||||
$message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
$message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->SendDebug($title, (string) $message, 0);
|
$this->SendDebug($title, (string) $message, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,87 @@
|
|||||||
# Shelly Modul
|
# Shelly Modul
|
||||||
|
|
||||||
> Status: Implementiert. Die fachliche Funktion von
|
> Status: Implementiert. Der Parser verarbeitet Shelly-NG-Komponenten der
|
||||||
> `Shelly_Parser_MQTT` wurde für IP-Symcon 8.0 bereinigt und ohne
|
> Generationen 2, 3 und 4 generisch und unabhängig vom Gerätenamen.
|
||||||
> EMS-Abhängigkeit in Enelix Utils adaptiert.
|
|
||||||
|
|
||||||
Das Modul verarbeitet Shelly Gen2+-Statusmeldungen über das native
|
Das Modul verarbeitet Shelly-RPC- und Statusmeldungen über das native
|
||||||
IP-Symcon-MQTT-Datenflussinterface. Ein interner MQTT Server wird beim
|
IP-Symcon-MQTT-Datenflussinterface. Es gehört zu Enelix Utils und besitzt
|
||||||
Erstellen als Standard-Parent angeboten; ein kompatibler MQTT Client kann
|
keine Abhängigkeit zum Enelix EMS.
|
||||||
manuell als Gateway verbunden werden.
|
|
||||||
|
|
||||||
## Ziel und Abgrenzung
|
## Ziel und Abgrenzung
|
||||||
|
|
||||||
- automatische Erkennung von Geräten und vorhandenen Schaltkanälen
|
- freie MQTT-Client-ID und freie ein- oder mehrstufige Topic-Präfixe
|
||||||
- Anzeige von Online-Status, Typ, Inputs, Outputs und Temperatur
|
- automatische Erkennung aller gemeldeten Komponenten und skalaren Werte
|
||||||
- Schalten erkannter Outputs über Shelly RPC `Switch.Set`
|
- vollständige Speicherung von Listen als JSON-String
|
||||||
- keine Geräteauswahl, Discovery-Instanz oder EMS-Kopplung
|
- konfigurierbare Erzeugung nach Datenpunktgruppen
|
||||||
- keine Unterstützung für Shelly Gen1-`shellies/...`-Topics
|
- weiterhin schaltbare `Switch.Set`-Ausgänge
|
||||||
- keine Verwaltung von MQTT-Zugangsdaten oder Shelly-Gerätekonfiguration
|
- keine Verwaltung von Broker- oder Shelly-Zugangsdaten
|
||||||
|
- keine Shelly-Gen1-`shellies/...`-Topics
|
||||||
|
|
||||||
|
Die generische Verarbeitung ist absichtlich nicht an eine statische Liste von
|
||||||
|
Gerätemodellen gekoppelt. Neue Komponenten landen in der Gruppe `Other` und
|
||||||
|
können damit ohne Moduländerung eingelesen werden.
|
||||||
|
|
||||||
## MQTT-Datenfluss
|
## MQTT-Datenfluss
|
||||||
|
|
||||||
| Richtung | Topic | Inhalt |
|
| Richtung | Topic | Inhalt |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Shelly nach Symcon | `<Topic>/online` | `true`/`false`, `1`/`0` oder `online`/`offline` |
|
| Shelly nach Symcon | `<Prefix>/online` | Online-Status |
|
||||||
| Shelly nach Symcon | `<Topic>/events/rpc` | RPC-Payload mit `src` und `params` |
|
| Shelly nach Symcon | `<Prefix>/events/rpc` | `NotifyStatus` oder `NotifyEvent` |
|
||||||
| Symcon nach Shelly | `<Topic>/rpc` | RPC-Aufruf `Switch.Set` |
|
| Shelly nach Symcon | `<Prefix>/status/<Komponente>` | Komponentenstatus |
|
||||||
|
| Shelly nach Symcon | `<Prefix>/status` | Gerätestatus |
|
||||||
|
| Shelly nach Symcon | `<Prefix>/announce` | Geräteinformationen |
|
||||||
|
| Symcon nach Shelly | `<Prefix>/rpc` | RPC-Aufruf `Switch.Set` |
|
||||||
|
|
||||||
Nur MQTT-Publish-Pakete mit `PacketType = 3` werden ausgewertet. Die erste
|
Der Gerätepräfix wird vom bekannten Topic-Ende her bestimmt. Dadurch sind
|
||||||
Topic-Ebene wird als Geräte-Topic verwendet. Der konfigurierte Präfix wird
|
`symcon/events/rpc` und `gebaeude/etage/aktor/events/rpc` gleichermassen
|
||||||
ohne Beachtung der Gross-/Kleinschreibung geprüft.
|
gültig. Die MQTT-Client-ID ist nicht Bestandteil der Erkennungslogik.
|
||||||
|
|
||||||
## Dynamische Variablen
|
|
||||||
|
|
||||||
`<Schlüssel>` ist ein aus dem Geräte-Topic abgeleiteter, für IP-Symcon
|
|
||||||
gültiger Ident-Bestandteil mit kurzem Hash.
|
|
||||||
|
|
||||||
| Ident-Muster | Typ / Zugriff | Beschreibung |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `<Schlüssel>_online` | Boolean / Anzeige | Online-Meldung. |
|
|
||||||
| `<Schlüssel>_type` | String / Anzeige | Aus der RPC-`src` extrahierter Modellteil. |
|
|
||||||
| `<Schlüssel>_input_n` | Boolean / Anzeige | `input:n.state` oder `switch:n.input`. |
|
|
||||||
| `<Schlüssel>_output_n` | Boolean / bedienbar | `switch:n.output`; schaltet per RPC. |
|
|
||||||
| `<Schlüssel>_temperature` | Float / Anzeige | Erster eindeutig erkannter Celsiuswert. |
|
|
||||||
|
|
||||||
Die Output-Aktion wartet auf die Rückmeldung des Geräts und setzt den
|
|
||||||
Variablenwert nicht vorab. Dynamisch angelegte Datenpunkte werden bei später
|
|
||||||
fehlenden Meldungen nicht gelöscht.
|
|
||||||
|
|
||||||
## Properties
|
## Properties
|
||||||
|
|
||||||
| Ident | Typ | Standard / Beschreibung |
|
| Ident | Standard | Beschreibung |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `DeviceTopicPrefix` | String | `shelly`; einfacher Präfix für die erste Topic-Ebene, leer akzeptiert alle gültigen Namen. |
|
| `UseDeviceTopicFilter` | `false` | Aktiviert den optionalen Präfixfilter. |
|
||||||
| `Debug` | Boolean | `false`; Instanz-Debug für empfangene/gesendete MQTT-Daten und Validierungsfehler. |
|
| `DeviceTopicPrefix` | `shelly` | Filterwert; ohne aktivierten Filter wirkungslos. |
|
||||||
|
| `CreateOnline` | `true` | Online-Status. |
|
||||||
|
| `CreateDeviceInfo` | `true` | Modell- und Geräteinformationen. |
|
||||||
|
| `CreateInputs` | `true` | Inputs und Eingangsereignisse. |
|
||||||
|
| `CreateSwitches` | `true` | Schaltausgänge und Schalterstatus. |
|
||||||
|
| `CreateCovers` | `true` | Cover- und Beschattungswerte. |
|
||||||
|
| `CreateLights` | `true` | Licht-, Dimm- und Farbwerte. |
|
||||||
|
| `CreatePower` | `true` | Wirk- und Scheinleistung. |
|
||||||
|
| `CreateVoltage` | `true` | Spannungswerte. |
|
||||||
|
| `CreateCurrent` | `true` | Stromwerte. |
|
||||||
|
| `CreateEnergy` | `true` | Bezogene und zurückgelieferte Energie. |
|
||||||
|
| `CreateFrequency` | `true` | Netzfrequenz. |
|
||||||
|
| `CreatePowerFactor` | `true` | Leistungsfaktor. |
|
||||||
|
| `CreateMeterDetails` | `true` | Weitere Zählerdaten. |
|
||||||
|
| `CreateTemperature` | `true` | Temperaturwerte. |
|
||||||
|
| `CreateHumidity` | `true` | Feuchtewerte. |
|
||||||
|
| `CreateIlluminance` | `true` | Helligkeit und Beleuchtungsstärke. |
|
||||||
|
| `CreateBattery` | `true` | Batterie- und Versorgungswerte. |
|
||||||
|
| `CreateEnvironment` | `true` | Weitere Umweltsensoren. |
|
||||||
|
| `CreateSystem` | `false` | System- und Netzwerkstatus. |
|
||||||
|
| `CreateOther` | `false` | Unbekannte und zukünftige Komponenten. |
|
||||||
|
| `Debug` | `false` | Diagnoseausgaben der Instanz. |
|
||||||
|
|
||||||
|
Ein deaktivierter Bereich wird weder neu angelegt noch aktualisiert. Eine
|
||||||
|
automatische Löschung bestehender Objekte findet nicht statt.
|
||||||
|
|
||||||
|
## Typabbildung
|
||||||
|
|
||||||
|
| Shelly-Wert | IP-Symcon-Typ |
|
||||||
|
| --- | --- |
|
||||||
|
| Boolean | Boolean |
|
||||||
|
| bekannte IDs, Revisionen und Zeitangaben | Integer |
|
||||||
|
| übrige numerische Messwerte | Float |
|
||||||
|
| String | String |
|
||||||
|
| Liste | JSON-String |
|
||||||
|
| `null` | wird ignoriert |
|
||||||
|
|
||||||
|
Verschachtelte Objekte werden rekursiv in stabile Pfade wie
|
||||||
|
`switch:0.aenergy.total` zerlegt. Technische Idents enthalten einen kurzen
|
||||||
|
Hash, damit Sonderzeichen, lange Pfade und ähnlich benannte Punkte nicht
|
||||||
|
kollidieren.
|
||||||
|
|
||||||
## Öffentliche Funktion
|
## Öffentliche Funktion
|
||||||
|
|
||||||
@@ -65,34 +94,21 @@ SHELLY_SetOutput(
|
|||||||
): void;
|
): void;
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Funktion validiert Topic-Präfix und Ausgangsindex. Ein fehlendes oder
|
Die Funktion validiert das Topic und den Ausgangsindex. Ein fehlendes oder
|
||||||
inaktives MQTT-Gateway führt zu einer verständlichen Exception.
|
inaktives MQTT-Gateway erzeugt eine verständliche Exception.
|
||||||
|
|
||||||
## Adaption aus Enelix 1
|
## Kompatibilität
|
||||||
|
|
||||||
| Teil | Entscheidung |
|
Die bestehenden Variablen für Online, Typ, Inputs, Switch-Ausgänge und die
|
||||||
| --- | --- |
|
Switch-Temperatur behalten ihre bisherigen Idents. Der frühere Präfixwert
|
||||||
| MQTT-Datenfluss-GUIDs und RPC-`Switch.Set` | angepasst übernommen |
|
`shelly` wirkt nach dem Update nur noch, wenn der neue Filter explizit
|
||||||
| dynamische Geräteordner und Datenpunkte | angepasst übernommen |
|
aktiviert wird.
|
||||||
| Parser für Inputs, Outputs und Temperatur | neu und strenger implementiert |
|
|
||||||
| technische Idents mit roher Shelly-ID | verworfen; Bindestriche sind in IP-Symcon-Idents ungültig |
|
|
||||||
| gemeinsames Action-Skript | angepasst übernommen und verborgen |
|
|
||||||
| globale `#`-Subscription | verworfen; das native MQTT-Gateway liefert Publish-Daten über den Datenfluss |
|
|
||||||
| bedingungsloses `IPS_LogMessage` | verworfen; Debug-Ausgabe respektiert die Property |
|
|
||||||
| optimistisches Setzen eines Outputs | verworfen; Status folgt der Gerätebestätigung |
|
|
||||||
|
|
||||||
## Betrieb und Diagnose
|
## Prüfung
|
||||||
|
|
||||||
1. MQTT-Gateway muss verbunden und aktiv sein.
|
- Parser-Tests für freie und mehrstufige Topics
|
||||||
2. Shelly MQTT muss RPC-Statusmeldungen publizieren.
|
- Tests für unbekannte Modelle und generische Komponenten
|
||||||
3. Geräte-Topic und `DeviceTopicPrefix` müssen zusammenpassen.
|
- Tests für Gruppenklassifikation, Ereignisse und Typabbildung
|
||||||
4. Bei fehlenden Variablen kurzzeitig `Debug` aktivieren und eine
|
- PHP-Syntaxprüfung und JSON-Prüfung
|
||||||
Statusänderung auslösen.
|
- Praxistest in IP-Symcon 8.0 mit projektseitigen Gen2-, Gen3- und
|
||||||
5. Zugangsdaten niemals in Logs oder Repository-Dokumentation übernehmen.
|
Gen4-Geräten bleibt nach dem Merge erforderlich
|
||||||
|
|
||||||
## Offene Punkte
|
|
||||||
|
|
||||||
- Praxistest mit den im Projekt eingesetzten Shelly-Modellen und deren
|
|
||||||
Firmwareständen.
|
|
||||||
- Bei Bedarf spätere Erweiterung um weitere Komponenten wie Cover, Light,
|
|
||||||
Meter oder Batteriestatus als separat beschlossene Funktionalität.
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user