506 lines
18 KiB
PHP
506 lines
18 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}';
|
|
|
|
private const GROUP_PROPERTIES = [
|
|
'device' => 'CreateDeviceInfo',
|
|
'inputs' => 'CreateInputs',
|
|
'switches' => 'CreateSwitches',
|
|
'covers' => 'CreateCovers',
|
|
'lights' => 'CreateLights',
|
|
'power' => 'CreatePower',
|
|
'voltage' => 'CreateVoltage',
|
|
'current' => 'CreateCurrent',
|
|
'energy' => 'CreateEnergy',
|
|
'frequency' => 'CreateFrequency',
|
|
'power_factor' => 'CreatePowerFactor',
|
|
'meters' => 'CreateMeterDetails',
|
|
'temperature' => 'CreateTemperature',
|
|
'humidity' => 'CreateHumidity',
|
|
'illuminance' => 'CreateIlluminance',
|
|
'battery' => 'CreateBattery',
|
|
'environment' => 'CreateEnvironment',
|
|
'system' => 'CreateSystem',
|
|
'other' => 'CreateOther',
|
|
];
|
|
|
|
public function Create()
|
|
{
|
|
parent::Create();
|
|
|
|
$this->RegisterPropertyBoolean('UseDeviceTopicFilter', false);
|
|
$this->RegisterPropertyString('DeviceTopicPrefix', 'shelly');
|
|
$this->RegisterPropertyBoolean('CreateOnline', true);
|
|
$this->RegisterPropertyBoolean('CreateDeviceInfo', true);
|
|
$this->RegisterPropertyBoolean('CreateInputs', true);
|
|
$this->RegisterPropertyBoolean('CreateSwitches', true);
|
|
$this->RegisterPropertyBoolean('CreateCovers', true);
|
|
$this->RegisterPropertyBoolean('CreateLights', true);
|
|
$this->RegisterPropertyBoolean('CreatePower', true);
|
|
$this->RegisterPropertyBoolean('CreateVoltage', true);
|
|
$this->RegisterPropertyBoolean('CreateCurrent', true);
|
|
$this->RegisterPropertyBoolean('CreateEnergy', true);
|
|
$this->RegisterPropertyBoolean('CreateFrequency', true);
|
|
$this->RegisterPropertyBoolean('CreatePowerFactor', true);
|
|
$this->RegisterPropertyBoolean('CreateMeterDetails', true);
|
|
$this->RegisterPropertyBoolean('CreateTemperature', true);
|
|
$this->RegisterPropertyBoolean('CreateHumidity', true);
|
|
$this->RegisterPropertyBoolean('CreateIlluminance', true);
|
|
$this->RegisterPropertyBoolean('CreateBattery', true);
|
|
$this->RegisterPropertyBoolean('CreateEnvironment', true);
|
|
$this->RegisterPropertyBoolean('CreateSystem', false);
|
|
$this->RegisterPropertyBoolean('CreateOther', false);
|
|
$this->RegisterPropertyBoolean('Debug', false);
|
|
$this->ConnectParent(self::MQTT_SERVER_MODULE_ID);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
$topicInfo = ShellyParser::parseTopic($topic);
|
|
if ($topicInfo === null || !$this->isAcceptedDeviceTopic($topicInfo['deviceTopic'])) {
|
|
return;
|
|
}
|
|
|
|
$this->debug('MQTT empfangen', $topic . ' -> ' . $payload);
|
|
$deviceTopic = $topicInfo['deviceTopic'];
|
|
|
|
if ($topicInfo['kind'] === 'online') {
|
|
$this->handleOnline($deviceTopic, $payload);
|
|
return;
|
|
}
|
|
|
|
$decoded = $this->decodePayload($payload);
|
|
if ($decoded === null) {
|
|
return;
|
|
}
|
|
|
|
if ($topicInfo['kind'] === 'rpc') {
|
|
$this->handleRpc($deviceTopic, $decoded);
|
|
} elseif ($topicInfo['kind'] === 'component_status' && $topicInfo['component'] !== null) {
|
|
$this->updatePoints($deviceTopic, ShellyParser::pointsFromComponent($topicInfo['component'], $decoded));
|
|
} elseif ($topicInfo['kind'] === 'status') {
|
|
$this->updatePoints($deviceTopic, ShellyParser::pointsFromStatus($decoded));
|
|
} elseif ($topicInfo['kind'] === 'announce') {
|
|
$this->updatePoints($deviceTopic, ShellyParser::pointsFromAnnounce($decoded));
|
|
}
|
|
}
|
|
|
|
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 ist ungültig oder entspricht nicht dem aktiven Filter.');
|
|
}
|
|
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 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
|
|
{
|
|
if (!$this->ReadPropertyBoolean('CreateOnline')) {
|
|
return;
|
|
}
|
|
|
|
$value = $this->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, array $rpc): void
|
|
{
|
|
$source = $rpc['src'] ?? '';
|
|
if ($this->ReadPropertyBoolean('CreateDeviceInfo') && is_string($source)) {
|
|
$type = ShellyParser::extractType($source);
|
|
if ($type !== 'unknown') {
|
|
$typeID = $this->ensureVariable($deviceTopic, 'type', 'Typ', 3, '', 20);
|
|
if ($typeID > 0) {
|
|
SetValue($typeID, $type);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->updatePoints($deviceTopic, ShellyParser::pointsFromRpc($rpc));
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{component: string, path: string, value: bool|int|float|string}> $points
|
|
*/
|
|
private function updatePoints(string $deviceTopic, array $points): void
|
|
{
|
|
foreach ($points as $point) {
|
|
$group = ShellyParser::groupForPoint($point['component'], $point['path']);
|
|
if (!$this->isGroupEnabled($group)) {
|
|
continue;
|
|
}
|
|
|
|
$definition = $this->pointDefinition($deviceTopic, $point);
|
|
$variableID = $this->ensureVariable(
|
|
$deviceTopic,
|
|
$definition['suffix'],
|
|
$definition['name'],
|
|
$definition['type'],
|
|
$definition['profile'],
|
|
$definition['position'],
|
|
$definition['action']
|
|
);
|
|
if ($variableID > 0) {
|
|
$value = $point['value'];
|
|
if ($definition['type'] === 1 && is_float($value)) {
|
|
$value = (int) $value;
|
|
} elseif ($definition['type'] === 2 && is_int($value)) {
|
|
$value = (float) $value;
|
|
}
|
|
SetValue($variableID, $value);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function isGroupEnabled(string $group): bool
|
|
{
|
|
$property = self::GROUP_PROPERTIES[$group] ?? 'CreateOther';
|
|
return $this->ReadPropertyBoolean($property);
|
|
}
|
|
|
|
/**
|
|
* @param array{component: string, path: string, value: bool|int|float|string} $point
|
|
* @return array{suffix: string, name: string, type: int, profile: string, position: int, action: bool}
|
|
*/
|
|
private function pointDefinition(string $deviceTopic, array $point): array
|
|
{
|
|
[$componentType, $componentIndex] = array_pad(explode(':', $point['component'], 2), 2, null);
|
|
if ($componentType === 'switch' && $point['path'] === 'output' && ctype_digit((string) $componentIndex)) {
|
|
$index = (int) $componentIndex;
|
|
return [
|
|
'suffix' => 'output_' . $index,
|
|
'name' => 'Output ' . $index,
|
|
'type' => 0,
|
|
'profile' => '~Switch',
|
|
'position' => 200 + $index,
|
|
'action' => true,
|
|
];
|
|
}
|
|
|
|
if (($componentType === 'input' && $point['path'] === 'state')
|
|
|| ($componentType === 'switch' && $point['path'] === 'input')) {
|
|
$index = ctype_digit((string) $componentIndex) ? (int) $componentIndex : 0;
|
|
return [
|
|
'suffix' => 'input_' . $index,
|
|
'name' => 'Input ' . $index,
|
|
'type' => 0,
|
|
'profile' => '',
|
|
'position' => 100 + $index,
|
|
'action' => false,
|
|
];
|
|
}
|
|
|
|
if ($componentType === 'switch' && str_ends_with(strtolower($point['path']), 'temperature.tc')) {
|
|
return [
|
|
'suffix' => 'temperature',
|
|
'name' => 'Temperatur',
|
|
'type' => 2,
|
|
'profile' => '~Temperature',
|
|
'position' => 300,
|
|
'action' => false,
|
|
];
|
|
}
|
|
|
|
$source = $point['component'] . '.' . $point['path'];
|
|
$normalized = strtolower($source);
|
|
$normalized = preg_replace('/[^a-z0-9_]+/', '_', $normalized) ?? 'value';
|
|
$normalized = trim($normalized, '_');
|
|
$suffix = 'dp_' . substr($normalized, 0, 48) . '_' . substr(hash('sha256', $source), 0, 8);
|
|
|
|
return [
|
|
'suffix' => $suffix,
|
|
'name' => $this->displayName($point['component'], $point['path']),
|
|
'type' => ShellyParser::variableType($point['value'], $point['path']),
|
|
'profile' => $this->profileForPoint($point['component'], $point['path']),
|
|
'position' => 1000 + (int) (hexdec(substr(hash('sha256', $source), 0, 4)) % 50000),
|
|
'action' => false,
|
|
];
|
|
}
|
|
|
|
private function displayName(string $component, string $path): string
|
|
{
|
|
$fieldNames = [
|
|
'apower' => 'Wirkleistung',
|
|
'aprtpower' => 'Scheinleistung',
|
|
'voltage' => 'Spannung',
|
|
'current' => 'Strom',
|
|
'freq' => 'Frequenz',
|
|
'pf' => 'Leistungsfaktor',
|
|
'total' => 'Gesamt',
|
|
'rh' => 'Luftfeuchtigkeit',
|
|
'tc' => 'Temperatur',
|
|
'current_pos' => 'Position',
|
|
'brightness' => 'Helligkeit',
|
|
];
|
|
|
|
$componentLabel = ucfirst(str_replace(':', ' ', $component));
|
|
$parts = explode('.', $path);
|
|
foreach ($parts as &$part) {
|
|
$lower = strtolower($part);
|
|
$part = $fieldNames[$lower] ?? ucfirst(str_replace('_', ' ', $part));
|
|
}
|
|
unset($part);
|
|
|
|
return $componentLabel . ' - ' . implode(' / ', $parts);
|
|
}
|
|
|
|
private function profileForPoint(string $component, string $path): string
|
|
{
|
|
$fullPath = strtolower($component . '.' . $path);
|
|
if (preg_match('/(^|\.)(temperature|tc)(\.|$)/', $fullPath) === 1) {
|
|
return '~Temperature';
|
|
}
|
|
if (preg_match('/(^|\.)(humidity|rh)(\.|$)/', $fullPath) === 1) {
|
|
return '~Humidity';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function ensureVariable(
|
|
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_VariableProfileExists($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) > 300 || preg_match('//u', $deviceTopic) !== 1) {
|
|
return false;
|
|
}
|
|
if (preg_match('/[\x00-\x1F\x7F#\+]/u', $deviceTopic) === 1) {
|
|
return false;
|
|
}
|
|
if (!$this->ReadPropertyBoolean('UseDeviceTopicFilter')) {
|
|
return true;
|
|
}
|
|
|
|
$prefix = trim($this->ReadPropertyString('DeviceTopicPrefix'));
|
|
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 toBoolean(string $value): ?bool
|
|
{
|
|
$normalized = strtolower(trim($value));
|
|
if (in_array($normalized, ['true', '1', 'on', 'online'], true)) {
|
|
return true;
|
|
}
|
|
if (in_array($normalized, ['false', '0', 'off', 'offline'], true)) {
|
|
return false;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function debug(string $title, $message): void
|
|
{
|
|
if (!$this->ReadPropertyBoolean('Debug')) {
|
|
return;
|
|
}
|
|
if (!is_string($message)) {
|
|
$message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
}
|
|
|
|
$this->SendDebug($title, (string) $message, 0);
|
|
}
|
|
}
|