@@ -4,18 +4,18 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Das Repository befindet sich im Aufbau. Der Verbrauchskostenreport ist als
|
Das Repository befindet sich im Aufbau. Verbrauchskostenreport und Shelly
|
||||||
installierbares IP-Symcon-Modul enthalten; die weiteren Module sind derzeit
|
Modul sind als installierbare IP-Symcon-Module enthalten; die weiteren Module
|
||||||
als Diskussionsentwürfe dokumentiert.
|
sind derzeit als Diskussionsentwürfe dokumentiert.
|
||||||
|
|
||||||
## Geplante Module
|
## Module
|
||||||
|
|
||||||
- Verbrauchskostenreport (implementiert)
|
- Verbrauchskostenreport (implementiert)
|
||||||
- Virtuelle Batterie
|
- Virtuelle Batterie
|
||||||
- CC100 Hardware
|
- CC100 Hardware
|
||||||
- Energiediagramm
|
- Energiediagramm
|
||||||
- VGT-Schnittstelle
|
- VGT-Schnittstelle
|
||||||
- Shelly Modul
|
- Shelly Modul (implementiert)
|
||||||
|
|
||||||
Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen
|
Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen
|
||||||
Punkten stehen in der [Modulübersicht](docs/module/README.md).
|
Punkten stehen in der [Modulübersicht](docs/module/README.md).
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Shelly Modul
|
||||||
|
|
||||||
|
Das Modul bindet Shelly-Geräte ab Generation 2 über deren MQTT/RPC-Protokoll
|
||||||
|
in IP-Symcon ein. Es erkennt Geräte und Datenpunkte aus Statusmeldungen
|
||||||
|
automatisch und benötigt keine Abhängigkeit zum Enelix EMS.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- IP-Symcon ab Version 8.0
|
||||||
|
- ein eingerichteter IP-Symcon MQTT Server oder MQTT Client
|
||||||
|
- ein Shelly Gen2+-Gerät mit aktivierter MQTT-Verbindung
|
||||||
|
- MQTT-RPC-Ereignisse auf `<Topic>/events/rpc` und der Online-Status auf
|
||||||
|
`<Topic>/online`
|
||||||
|
|
||||||
|
Shelly-Geräte der ersten Generation mit `shellies/...`-Topics werden nicht
|
||||||
|
unterstützt.
|
||||||
|
|
||||||
|
## Einrichtung
|
||||||
|
|
||||||
|
1. Das Modul über die Enelix-Utils-Bibliothek installieren und eine Instanz
|
||||||
|
`Shelly Modul` anlegen.
|
||||||
|
2. Das gewünschte MQTT-Gateway verbinden. Neue Instanzen verwenden
|
||||||
|
standardmässig den internen MQTT Server; für einen externen Broker kann ein
|
||||||
|
MQTT Client als Gateway gewählt werden.
|
||||||
|
3. Am Shelly MQTT aktivieren und den Broker, Port und gegebenenfalls
|
||||||
|
Zugangsdaten eintragen. Zugangsdaten gehören ausschliesslich in die
|
||||||
|
MQTT-/Shelly-Konfiguration und nicht in dieses Modul.
|
||||||
|
4. Den Topic-Präfix prüfen und die Instanz übernehmen.
|
||||||
|
5. Eine Statusänderung am Gerät auslösen. Der Geräteordner und seine Variablen
|
||||||
|
werden mit der ersten passenden Meldung angelegt.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
| Property | Standard | Beschreibung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DeviceTopicPrefix` | `shelly` | Nur erste Topic-Ebenen mit diesem Präfix werden verarbeitet; Vergleich ohne Beachtung der Gross-/Kleinschreibung. Leer erlaubt alle gültigen Topic-Namen. |
|
||||||
|
| `Debug` | `false` | Schreibt verarbeitete und gesendete MQTT-Nachrichten in den Instanz-Debug. |
|
||||||
|
|
||||||
|
Ein benutzerdefinierter Shelly-`topic_prefix` muss mit
|
||||||
|
`DeviceTopicPrefix` übereinstimmen. Das Feld ist ein einfacher Präfix und
|
||||||
|
kein regulärer Ausdruck.
|
||||||
|
|
||||||
|
## Automatisch angelegte Objekte
|
||||||
|
|
||||||
|
Für jede erste MQTT-Topic-Ebene wird ein Geräteordner angelegt. Technische
|
||||||
|
Idents bestehen nur aus den von IP-Symcon erlaubten Zeichen und enthalten
|
||||||
|
zusätzlich einen kurzen Hash des originalen Topic-Namens. Damit bleiben auch
|
||||||
|
Namen mit Bindestrichen stabil und kollisionsarm.
|
||||||
|
|
||||||
|
| Anzeige | Typ / Zugriff | MQTT-Quelle |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Online | Boolean / Anzeige | `<Topic>/online` |
|
||||||
|
| Typ | String / Anzeige | `src` einer RPC-Meldung |
|
||||||
|
| Input n | Boolean / Anzeige | `input:n.state` oder `switch:n.input` |
|
||||||
|
| Output n | Boolean / bedienbar | `switch:n.output` |
|
||||||
|
| Temperatur | Float / Anzeige | erster erkannter Celsiuswert in `temperature`/`tC` |
|
||||||
|
|
||||||
|
Neue Inputs und Outputs werden dynamisch ergänzt. Bestehende Objekte werden
|
||||||
|
nicht automatisch gelöscht, wenn ein Datenpunkt später nicht mehr gemeldet
|
||||||
|
wird.
|
||||||
|
|
||||||
|
## Ausgänge schalten
|
||||||
|
|
||||||
|
Eine Bedienung von `Output n` veröffentlicht einen Shelly-RPC-Aufruf
|
||||||
|
`Switch.Set` auf `<Topic>/rpc`. Der Variablenwert wird nicht optimistisch
|
||||||
|
gesetzt; er folgt der nächsten Statusmeldung des Geräts. Dadurch zeigt
|
||||||
|
IP-Symcon keinen erfolgreichen Schaltvorgang an, wenn der Broker oder das
|
||||||
|
Gerät den Auftrag nicht ausführt.
|
||||||
|
|
||||||
|
Aus Skripten kann derselbe Befehl direkt aufgerufen werden:
|
||||||
|
|
||||||
|
```php
|
||||||
|
SHELLY_SetOutput($instanceID, 'shellyplus1pm-a1b2c3', 0, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unterstützte RPC-Struktur
|
||||||
|
|
||||||
|
Das Modul verarbeitet Publish-Pakete (`PacketType = 3`) mit JSON-Payloads
|
||||||
|
nach folgendem Muster:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"src": "shellyplus1pm-a1b2c3",
|
||||||
|
"method": "NotifyStatus",
|
||||||
|
"params": {
|
||||||
|
"switch:0": {
|
||||||
|
"output": true,
|
||||||
|
"temperature": {"tC": 42.5}
|
||||||
|
},
|
||||||
|
"input:0": {"state": false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unvollständige, ungültige oder nicht zum konfigurierten Topic-Präfix passende
|
||||||
|
Nachrichten werden ignoriert. Bei aktiviertem Debug werden fehlerhafte
|
||||||
|
JSON-Payloads und ignorierte Online-Werte nachvollziehbar protokolliert.
|
||||||
|
|
||||||
|
## Hinweise zur Adaption
|
||||||
|
|
||||||
|
Aus `Shelly_Parser_MQTT` von Enelix 1 wurden MQTT/RPC-Datenfluss,
|
||||||
|
Geräteordner, dynamische Inputs/Outputs, Temperatur und `Switch.Set`
|
||||||
|
fachlich übernommen. Neu implementiert wurden gültige stabile Idents,
|
||||||
|
tatsächlich schaltbares Debug-Logging, strikte Payload-Prüfung, ein
|
||||||
|
konfigurierbarer Topic-Präfix sowie verständliche Fehler bei einem inaktiven
|
||||||
|
MQTT-Gateway. Die pauschale MQTT-`#`-Subscription und dauerhaftes
|
||||||
|
System-Logging wurden verworfen.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "Label",
|
||||||
|
"caption": "Verarbeitet MQTT/RPC-Statusmeldungen von Shelly Gen2+ und legt erkannte Geräte und Datenpunkte automatisch an."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ValidationTextBox",
|
||||||
|
"name": "DeviceTopicPrefix",
|
||||||
|
"caption": "Erlaubter MQTT-Topic-Präfix"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "CheckBox",
|
||||||
|
"name": "Debug",
|
||||||
|
"caption": "Debug-Ausgaben aktivieren"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Label",
|
||||||
|
"caption": "MQTT wird am Shelly-Gerät konfiguriert. Standardmässig wird der interne MQTT Server verwendet; ein vorhandener MQTT Client kann als Gateway gewählt werden."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class ShellyParser
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extrahiert den Modellteil aus einer Shelly-RPC-Source.
|
||||||
|
*/
|
||||||
|
public static function extractType(string $source): string
|
||||||
|
{
|
||||||
|
if (preg_match('/^shelly([a-z0-9]+)-/i', trim($source), $matches) !== 1) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{outputs: array<int, bool>, inputs: array<int, bool>, temperature: ?float}
|
||||||
|
*/
|
||||||
|
public static function mapParams(array $params): array
|
||||||
|
{
|
||||||
|
$mapped = [
|
||||||
|
'outputs' => [],
|
||||||
|
'inputs' => [],
|
||||||
|
'temperature' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($params as $component => $value) {
|
||||||
|
if (!is_string($component) || !is_array($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^switch:(\d+)$/', $component, $matches) === 1) {
|
||||||
|
$index = (int) $matches[1];
|
||||||
|
$output = self::toBoolean($value['output'] ?? null);
|
||||||
|
$input = self::toBoolean($value['input'] ?? null);
|
||||||
|
|
||||||
|
if ($output !== null) {
|
||||||
|
$mapped['outputs'][$index] = $output;
|
||||||
|
}
|
||||||
|
if ($input !== null) {
|
||||||
|
$mapped['inputs'][$index] = $input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^input:(\d+)$/', $component, $matches) === 1) {
|
||||||
|
$state = self::toBoolean($value['state'] ?? null);
|
||||||
|
if ($state !== null) {
|
||||||
|
$mapped['inputs'][(int) $matches[1]] = $state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapped['temperature'] = self::findTemperature($params);
|
||||||
|
ksort($mapped['outputs']);
|
||||||
|
ksort($mapped['inputs']);
|
||||||
|
|
||||||
|
return $mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function toBoolean($value): ?bool
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === 1 || $value === '1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($value === 0 || $value === '0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
$normalized = strtolower(trim($value));
|
||||||
|
if (in_array($normalized, ['true', 'on', 'online'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (in_array($normalized, ['false', 'off', 'offline'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function findTemperature(array $data): ?float
|
||||||
|
{
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (!is_string($key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lowerKey = strtolower($key);
|
||||||
|
if ($lowerKey === 'temperature') {
|
||||||
|
$temperature = self::temperatureFromValue($value);
|
||||||
|
if ($temperature !== null) {
|
||||||
|
return $temperature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lowerKey === 'tc' && is_numeric($value)) {
|
||||||
|
return (float) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$temperature = self::findTemperature($value);
|
||||||
|
if ($temperature !== null) {
|
||||||
|
return $temperature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function temperatureFromValue($value): ?float
|
||||||
|
{
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (float) $value;
|
||||||
|
}
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['tC', 'tc', 't'] as $key) {
|
||||||
|
if (array_key_exists($key, $value) && is_numeric($value[$key])) {
|
||||||
|
return (float) $value[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "{21D82EDB-AC8E-4F3D-9FEE-D20BCAFC1C33}",
|
||||||
|
"name": "Shelly Modul",
|
||||||
|
"type": 3,
|
||||||
|
"vendor": "Belevo AG",
|
||||||
|
"aliases": [
|
||||||
|
"Shelly MQTT Parser"
|
||||||
|
],
|
||||||
|
"parentRequirements": [
|
||||||
|
"{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}"
|
||||||
|
],
|
||||||
|
"childRequirements": [],
|
||||||
|
"implemented": [
|
||||||
|
"{7F7632D9-FA40-4F38-8DEA-C83CD4325A32}"
|
||||||
|
],
|
||||||
|
"prefix": "SHELLY",
|
||||||
|
"url": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/libs/ShellyParser.php';
|
||||||
|
|
||||||
|
class ShellyModul extends IPSModule
|
||||||
|
{
|
||||||
|
private const MQTT_SERVER_MODULE_ID = '{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}';
|
||||||
|
private const MQTT_TX_DATA_ID = '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}';
|
||||||
|
|
||||||
|
public function Create()
|
||||||
|
{
|
||||||
|
parent::Create();
|
||||||
|
|
||||||
|
$this->RegisterPropertyString('DeviceTopicPrefix', 'shelly');
|
||||||
|
$this->RegisterPropertyBoolean('Debug', false);
|
||||||
|
$this->ConnectParent(self::MQTT_SERVER_MODULE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ApplyChanges()
|
||||||
|
{
|
||||||
|
parent::ApplyChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ReceiveData($JSONString)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = json_decode((string) $JSONString, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (JsonException $exception) {
|
||||||
|
$this->debug('Ungültiges MQTT-Datenpaket', $exception->getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($data) || (int) ($data['PacketType'] ?? 0) !== 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$topic = $data['Topic'] ?? null;
|
||||||
|
$payload = $data['Payload'] ?? null;
|
||||||
|
if (!is_string($topic) || !is_string($payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$topicParts = explode('/', $topic);
|
||||||
|
if (count($topicParts) < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceTopic = array_shift($topicParts);
|
||||||
|
if (!$this->isAcceptedDeviceTopic($deviceTopic)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->debug('MQTT empfangen', $topic . ' -> ' . $payload);
|
||||||
|
|
||||||
|
if ($topicParts === ['online']) {
|
||||||
|
$this->handleOnline($deviceTopic, $payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($topicParts === ['events', 'rpc']) {
|
||||||
|
$this->handleRpc($deviceTopic, $payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function RequestAction($Ident, $Value)
|
||||||
|
{
|
||||||
|
$parts = explode(':', (string) $Ident, 2);
|
||||||
|
if (count($parts) !== 2 || !ctype_digit($parts[0])) {
|
||||||
|
throw new InvalidArgumentException('Ungültige Shelly-Aktion: ' . (string) $Ident);
|
||||||
|
}
|
||||||
|
|
||||||
|
$folderID = (int) $parts[0];
|
||||||
|
$variableIdent = $parts[1];
|
||||||
|
if (!IPS_ObjectExists($folderID) || IPS_GetParent($folderID) !== $this->InstanceID) {
|
||||||
|
throw new InvalidArgumentException('Der Geräteordner gehört nicht zu dieser Instanz.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/_output_(\d+)$/', $variableIdent, $matches) !== 1) {
|
||||||
|
throw new InvalidArgumentException('Unbekannter Shelly-Ausgang: ' . $variableIdent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variableID = @IPS_GetObjectIDByIdent($variableIdent, $folderID);
|
||||||
|
if ($variableID === false || !IPS_VariableExists($variableID)) {
|
||||||
|
throw new InvalidArgumentException('Die Ausgangsvariable wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->SetOutput(IPS_GetName($folderID), (int) $matches[1], (bool) $Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function SetOutput(string $DeviceTopic, int $Output, bool $Value): void
|
||||||
|
{
|
||||||
|
if (!$this->isAcceptedDeviceTopic($DeviceTopic)) {
|
||||||
|
throw new InvalidArgumentException('Das Geräte-Topic entspricht nicht dem konfigurierten Präfix.');
|
||||||
|
}
|
||||||
|
if ($Output < 0) {
|
||||||
|
throw new InvalidArgumentException('Der Ausgangsindex darf nicht negativ sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'id' => random_int(1, 2147483647),
|
||||||
|
'src' => 'enelix-symcon-' . $this->InstanceID,
|
||||||
|
'method' => 'Switch.Set',
|
||||||
|
'params' => [
|
||||||
|
'id' => $Output,
|
||||||
|
'on' => $Value,
|
||||||
|
],
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$this->publish($DeviceTopic . '/rpc', $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleOnline(string $deviceTopic, string $payload): void
|
||||||
|
{
|
||||||
|
$value = ShellyParser::toBoolean($payload);
|
||||||
|
if ($value === null) {
|
||||||
|
$this->debug('Online-Status ignoriert', $payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variableID = $this->ensureVariable($deviceTopic, 'online', 'Online', 0, '', 10);
|
||||||
|
if ($variableID > 0) {
|
||||||
|
SetValue($variableID, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleRpc(string $deviceTopic, string $payload): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$rpc = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (JsonException $exception) {
|
||||||
|
$this->debug('Ungültiges Shelly-RPC', $exception->getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($rpc)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $rpc['src'] ?? '';
|
||||||
|
if (!is_string($source) || ShellyParser::extractType($source) === 'unknown') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = $rpc['params'] ?? null;
|
||||||
|
if (!is_array($params)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeID = $this->ensureVariable($deviceTopic, 'type', 'Typ', 3, '', 20);
|
||||||
|
if ($typeID > 0) {
|
||||||
|
SetValue($typeID, ShellyParser::extractType($source));
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapped = ShellyParser::mapParams($params);
|
||||||
|
foreach ($mapped['inputs'] as $index => $value) {
|
||||||
|
$variableID = $this->ensureVariable(
|
||||||
|
$deviceTopic,
|
||||||
|
'input_' . $index,
|
||||||
|
'Input ' . $index,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
100 + $index
|
||||||
|
);
|
||||||
|
if ($variableID > 0) {
|
||||||
|
SetValue($variableID, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($mapped['outputs'] as $index => $value) {
|
||||||
|
$variableID = $this->ensureVariable(
|
||||||
|
$deviceTopic,
|
||||||
|
'output_' . $index,
|
||||||
|
'Output ' . $index,
|
||||||
|
0,
|
||||||
|
'~Switch',
|
||||||
|
200 + $index,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if ($variableID > 0) {
|
||||||
|
SetValue($variableID, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mapped['temperature'] !== null) {
|
||||||
|
$variableID = $this->ensureVariable(
|
||||||
|
$deviceTopic,
|
||||||
|
'temperature',
|
||||||
|
'Temperatur',
|
||||||
|
2,
|
||||||
|
'~Temperature',
|
||||||
|
300
|
||||||
|
);
|
||||||
|
if ($variableID > 0) {
|
||||||
|
SetValue($variableID, $mapped['temperature']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureVariable(
|
||||||
|
string $deviceTopic,
|
||||||
|
string $suffix,
|
||||||
|
string $name,
|
||||||
|
int $type,
|
||||||
|
string $profile,
|
||||||
|
int $position,
|
||||||
|
bool $action = false
|
||||||
|
): int {
|
||||||
|
$folderID = $this->ensureDeviceFolder($deviceTopic);
|
||||||
|
$ident = $this->deviceKey($deviceTopic) . '_' . $suffix;
|
||||||
|
$variableID = @IPS_GetObjectIDByIdent($ident, $folderID);
|
||||||
|
|
||||||
|
if ($variableID !== false) {
|
||||||
|
if (!IPS_VariableExists($variableID) || IPS_GetVariable($variableID)['VariableType'] !== $type) {
|
||||||
|
$this->debug('Variablentyp-Konflikt', $ident);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$variableID = IPS_CreateVariable($type);
|
||||||
|
IPS_SetParent($variableID, $folderID);
|
||||||
|
IPS_SetIdent($variableID, $ident);
|
||||||
|
}
|
||||||
|
|
||||||
|
IPS_SetName($variableID, $name);
|
||||||
|
IPS_SetPosition($variableID, $position);
|
||||||
|
if ($profile !== '') {
|
||||||
|
IPS_SetVariableCustomProfile($variableID, $profile);
|
||||||
|
}
|
||||||
|
if ($action) {
|
||||||
|
$scriptID = $this->ensureActionScript();
|
||||||
|
if ($scriptID > 0) {
|
||||||
|
IPS_SetVariableCustomAction($variableID, $scriptID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variableID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureDeviceFolder(string $deviceTopic): int
|
||||||
|
{
|
||||||
|
$ident = 'folder_' . $this->deviceKey($deviceTopic);
|
||||||
|
$folderID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
||||||
|
if ($folderID !== false) {
|
||||||
|
return $folderID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folderID = IPS_CreateCategory();
|
||||||
|
IPS_SetParent($folderID, $this->InstanceID);
|
||||||
|
IPS_SetIdent($folderID, $ident);
|
||||||
|
IPS_SetName($folderID, $deviceTopic);
|
||||||
|
IPS_SetInfo($folderID, 'Automatisch aus MQTT-Topic ' . $deviceTopic . ' angelegt.');
|
||||||
|
|
||||||
|
return $folderID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureActionScript(): int
|
||||||
|
{
|
||||||
|
$ident = 'action_handler';
|
||||||
|
$scriptID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
||||||
|
|
||||||
|
if ($scriptID === false) {
|
||||||
|
$scriptID = IPS_CreateScript(0);
|
||||||
|
IPS_SetParent($scriptID, $this->InstanceID);
|
||||||
|
IPS_SetIdent($scriptID, $ident);
|
||||||
|
} elseif (!IPS_ScriptExists($scriptID)) {
|
||||||
|
$this->debug('Action Handler', 'Ident ist bereits durch ein anderes Objekt belegt.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPS_SetName($scriptID, 'Shelly Action Handler');
|
||||||
|
IPS_SetHidden($scriptID, true);
|
||||||
|
IPS_SetScriptContent($scriptID, <<<'PHP'
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$variableID = $_IPS['VARIABLE'];
|
||||||
|
$folderID = IPS_GetParent($variableID);
|
||||||
|
$moduleID = IPS_GetParent($folderID);
|
||||||
|
$ident = IPS_GetObject($variableID)['ObjectIdent'];
|
||||||
|
|
||||||
|
IPS_RequestAction($moduleID, $folderID . ':' . $ident, $_IPS['VALUE']);
|
||||||
|
PHP
|
||||||
|
);
|
||||||
|
|
||||||
|
return $scriptID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publish(string $topic, string $payload): void
|
||||||
|
{
|
||||||
|
$parentID = (int) (IPS_GetInstance($this->InstanceID)['ConnectionID'] ?? 0);
|
||||||
|
if ($parentID === 0 || !IPS_InstanceExists($parentID)) {
|
||||||
|
throw new RuntimeException('Kein MQTT-Gateway verbunden.');
|
||||||
|
}
|
||||||
|
if ((int) IPS_GetInstance($parentID)['InstanceStatus'] !== 102) {
|
||||||
|
throw new RuntimeException('Das verbundene MQTT-Gateway ist nicht aktiv.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$packet = json_encode([
|
||||||
|
'DataID' => self::MQTT_TX_DATA_ID,
|
||||||
|
'PacketType' => 3,
|
||||||
|
'QualityOfService' => 0,
|
||||||
|
'Retain' => false,
|
||||||
|
'Topic' => $topic,
|
||||||
|
'Payload' => $payload,
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$this->SendDataToParent($packet);
|
||||||
|
$this->debug('MQTT gesendet', $topic . ' -> ' . $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAcceptedDeviceTopic(string $deviceTopic): bool
|
||||||
|
{
|
||||||
|
if ($deviceTopic === '' || strlen($deviceTopic) > 128) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (preg_match('//u', $deviceTopic) !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (preg_match('/[\x00-\x1F\x7F\/#\+]/u', $deviceTopic) === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = trim($this->ReadPropertyString('DeviceTopicPrefix'));
|
||||||
|
return $prefix === '' || strncasecmp($deviceTopic, $prefix, strlen($prefix)) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deviceKey(string $deviceTopic): string
|
||||||
|
{
|
||||||
|
$normalized = strtolower($deviceTopic);
|
||||||
|
$normalized = preg_replace('/[^a-z0-9_]+/', '_', $normalized) ?? 'device';
|
||||||
|
$normalized = trim($normalized, '_');
|
||||||
|
if ($normalized === '') {
|
||||||
|
$normalized = 'device';
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($normalized, 0, 48) . '_' . substr(hash('sha256', $deviceTopic), 0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function debug(string $title, $message): void
|
||||||
|
{
|
||||||
|
if (!$this->ReadPropertyBoolean('Debug')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($message)) {
|
||||||
|
$message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
$this->SendDebug($title, (string) $message, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Modulübersicht Enelix Utils
|
# Modulübersicht Enelix Utils
|
||||||
|
|
||||||
> Status: Der Verbrauchskostenreport ist implementiert. Die übrigen Module
|
> Status: Verbrauchskostenreport und Shelly Modul sind implementiert. Die
|
||||||
> sind als Diskussionsentwürfe dokumentiert.
|
> übrigen Module sind als Diskussionsentwürfe dokumentiert.
|
||||||
|
|
||||||
| Modul | Herkunft | EMS-Abhängigkeit |
|
| Modul | Herkunft | EMS-Abhängigkeit |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -1,31 +1,98 @@
|
|||||||
# Shelly Modul
|
# Shelly Modul
|
||||||
|
|
||||||
> Status: Diskussionsentwurf. Übernimmt `Shelly_Parser_MQTT` möglichst
|
> Status: Implementiert. Die fachliche Funktion von
|
||||||
> unverändert und ohne EMS-Abhängigkeit.
|
> `Shelly_Parser_MQTT` wurde für IP-Symcon 8.0 bereinigt und ohne
|
||||||
|
> EMS-Abhängigkeit in Enelix Utils adaptiert.
|
||||||
|
|
||||||
Das Modul lauscht auf MQTT und legt pro erkanntem Gerät weiterhin einen Ordner
|
Das Modul verarbeitet Shelly Gen2+-Statusmeldungen über das native
|
||||||
mit Online-Status, Typ, Inputs, Outputs und Temperatur an.
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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` |
|
||||||
|
|
||||||
|
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
|
## 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 |
|
| Ident-Muster | Typ / Zugriff | Beschreibung |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `<Geraet>_online` | Boolean / Anzeige | Online-Meldung. |
|
| `<Schlüssel>_online` | Boolean / Anzeige | Online-Meldung. |
|
||||||
| `<Geraet>_type` | String / Anzeige | Erkannter Gerätetyp. |
|
| `<Schlüssel>_type` | String / Anzeige | Aus der RPC-`src` extrahierter Modellteil. |
|
||||||
| `<Geraet>_input_n` | Boolean / Anzeige | Dynamisch erkannter Eingang. |
|
| `<Schlüssel>_input_n` | Boolean / Anzeige | `input:n.state` oder `switch:n.input`. |
|
||||||
| `<Geraet>_output_n` | Boolean / bedienbar | Ausgang über vorhandenen Action Handler. |
|
| `<Schlüssel>_output_n` | Boolean / bedienbar | `switch:n.output`; schaltet per RPC. |
|
||||||
| `<Geraet>_temperature` | Float / Anzeige | Erkannte Temperatur. |
|
| `<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 | Typ | Standard / Beschreibung |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `Debug` | Boolean | `false`; bestehendes Debug-Logging. |
|
| `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. |
|
||||||
|
|
||||||
## Verhalten und offene Punkte
|
## Öffentliche Funktion
|
||||||
|
|
||||||
- MQTT-Abonnements, Geräteordner, technische Idents und der interne Action
|
```php
|
||||||
Handler bleiben erhalten.
|
SHELLY_SetOutput(
|
||||||
- In diesem Schritt keine Geräte- oder Datenpunktauswahl und keine neue
|
int $InstanzID,
|
||||||
Objektstruktur einführen.
|
string $DeviceTopic,
|
||||||
- Beim Praxistest klären, ob der bestehende Debug-Schalter ausreicht.
|
int $Output,
|
||||||
|
bool $Value
|
||||||
|
): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Funktion validiert Topic-Präfix und Ausgangsindex. Ein fehlendes oder
|
||||||
|
inaktives MQTT-Gateway führt zu einer verständlichen Exception.
|
||||||
|
|
||||||
|
## Adaption aus Enelix 1
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## Betrieb und Diagnose
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ final class DokumentationsstrukturTest extends TestCase
|
|||||||
'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'],
|
'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'],
|
||||||
'Energiediagramm' => ['Energiediagramm', 'Status: Diskussionsentwurf'],
|
'Energiediagramm' => ['Energiediagramm', 'Status: Diskussionsentwurf'],
|
||||||
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
|
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
|
||||||
'Shelly-Modul' => ['Shelly-Modul', 'Status: Diskussionsentwurf'],
|
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user