Files
Enelix-Utils/ShellyModul/module.php
T
dh 4cbec75d56
Tests / test (push) Successful in 42s
Shelly MQTT Modul implementieren
2026-09-17 09:46:07 +00:00

351 lines
11 KiB
PHP

<?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);
}
}