feat(utils): adapt WAGO CC100 hardware module

This commit is contained in:
dh
2026-09-27 08:50:16 +00:00
parent db704dd011
commit 92eec804a7
15 changed files with 1407 additions and 26 deletions
+405
View File
@@ -0,0 +1,405 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\CC100\CC100ChannelModel;
require_once __DIR__ . '/libs/CC100ChannelModel.php';
class CC100Hardware extends IPSModule
{
private const STATUS_CONFIGURATION_INVALID = 201;
private const STATUS_HARDWARE_UNAVAILABLE = 202;
private const STATUS_WRITE_FAILED = 203;
public function Create()
{
parent::Create();
$this->RegisterPropertyString(
'Kanalauswahl',
json_encode(CC100ChannelModel::defaultSelection(), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
$this->RegisterPropertyString(
'DigitalInputPath',
'/sys/devices/platform/soc/44009000.spi/spi_master/spi0/spi0.0/din'
);
$this->RegisterPropertyString('DigitalOutputPath', '/sys/kernel/dout_drv/DOUT_DATA');
$this->RegisterPropertyString('CalibrationPath', '/etc/calib');
$this->RegisterPropertyString('AI1Path', '/sys/bus/iio/devices/iio:device3/in_voltage3_raw');
$this->RegisterPropertyString('AI2Path', '/sys/bus/iio/devices/iio:device3/in_voltage0_raw');
$this->RegisterPropertyString('AO1ValuePath', '/sys/bus/iio/devices/iio:device0/out_voltage1_raw');
$this->RegisterPropertyString('AO1PowerPath', '/sys/bus/iio/devices/iio:device0/out_voltage1_powerdown');
$this->RegisterPropertyString('AO2ValuePath', '/sys/bus/iio/devices/iio:device1/out_voltage2_raw');
$this->RegisterPropertyString('AO2PowerPath', '/sys/bus/iio/devices/iio:device1/out_voltage2_powerdown');
$this->RegisterPropertyString('PT1Path', '/sys/bus/iio/devices/iio:device2/in_voltage13_raw');
$this->RegisterPropertyString('PT2Path', '/sys/bus/iio/devices/iio:device2/in_voltage1_raw');
$this->RegisterPropertyBoolean('Debug', false);
$this->RegisterAttributeString('LastPoll', '{}');
$this->RegisterAttributeString('Calibration', '{}');
$this->RegisterTimer(
'PollChannels',
0,
'IPS_RequestAction(' . $this->InstanceID . ', "Poll", true);'
);
$this->RegisterVariableInteger('Hardwarestatus', 'Hardwarestatus', '', 900);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 901);
}
public function ApplyChanges()
{
parent::ApplyChanges();
try {
$channels = CC100ChannelModel::normalizeSelection($this->ReadPropertyString('Kanalauswahl'));
$this->synchronizeVariables($channels);
$activeChannels = array_values(array_filter(
$channels,
static fn (array $channel): bool => $channel['active']
));
$this->SetSummary(count($activeChannels) . ' Kanaele aktiv');
$this->WriteAttributeString('LastPoll', '{}');
$this->WriteAttributeString('Calibration', '{}');
if ($activeChannels === []) {
$this->SetTimerInterval('PollChannels', 0);
$this->setHardwareStatus(0, 'Keine Kanaele aktiviert.');
$this->SetStatus(IS_ACTIVE);
return;
}
$intervals = array_column($activeChannels, 'pollInterval');
$this->SetTimerInterval('PollChannels', min($intervals));
$this->PollChannels(true);
} catch (Throwable $exception) {
$this->SetTimerInterval('PollChannels', 0);
$this->setHardwareStatus(3, $exception->getMessage());
$this->SetStatus(self::STATUS_CONFIGURATION_INVALID);
$this->debug('Konfiguration', $exception->getMessage());
}
}
public function RequestAction($Ident, $Value)
{
if ($Ident === 'Poll') {
$this->PollChannels((bool) $Value);
return;
}
if (preg_match('/^(DO|AO)([1-9][0-9]*)$/', (string) $Ident, $matches) !== 1) {
throw new InvalidArgumentException('Unbekannte Aktion ' . $Ident . '.');
}
$type = $matches[1];
$channel = (int) $matches[2];
if ($type === 'DO') {
$this->SetDigitalOutput($channel, (bool) $Value);
return;
}
if (!is_int($Value) && !is_float($Value)) {
throw new InvalidArgumentException('Analoger Ausgang erwartet einen Zahlenwert in Volt.');
}
$this->SetAnalogOutput($channel, (float) $Value);
}
public function PollChannels(bool $force = false): void
{
try {
$channels = array_values(array_filter(
CC100ChannelModel::normalizeSelection($this->ReadPropertyString('Kanalauswahl')),
static fn (array $channel): bool => $channel['active']
));
} catch (Throwable $exception) {
$this->setHardwareStatus(3, $exception->getMessage());
$this->SetStatus(self::STATUS_CONFIGURATION_INVALID);
return;
}
if ($channels === []) {
return;
}
$now = (int) round(microtime(true) * 1000);
$lastPoll = json_decode($this->ReadAttributeString('LastPoll'), true);
if (!is_array($lastPoll)) {
$lastPoll = [];
}
$due = array_values(array_filter(
$channels,
static fn (array $channel): bool => $force
|| $now - (int) ($lastPoll[$channel['key']] ?? 0) >= $channel['pollInterval']
));
if ($due === []) {
return;
}
$errors = [];
$fileCache = [];
foreach ($due as $channel) {
try {
$value = $this->readChannel($channel, $fileCache);
$this->setValueIfChanged($channel['key'], $value);
} catch (Throwable $exception) {
$errors[] = $channel['key'] . ': ' . $exception->getMessage();
$this->debug('Lesefehler ' . $channel['key'], $exception->getMessage());
}
$lastPoll[$channel['key']] = $now;
}
$this->WriteAttributeString(
'LastPoll',
json_encode($lastPoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
if ($errors === []) {
$this->setHardwareStatus(1, '');
$this->SetStatus(IS_ACTIVE);
return;
}
$this->setHardwareStatus(2, implode(' | ', $errors));
$this->SetStatus(self::STATUS_HARDWARE_UNAVAILABLE);
}
public function SetDigitalOutput(int $channel, bool $state): void
{
$key = 'DO' . $channel;
$this->assertWritableSelected($key);
try {
$path = $this->ReadPropertyString('DigitalOutputPath');
$aggregate = CC100ChannelModel::parseInteger($this->readFile($path));
$newValue = CC100ChannelModel::withBitState($aggregate, $channel, $state);
if ($newValue !== $aggregate) {
$this->writeFile($path, (string) $newValue);
}
$this->setValueIfChanged($key, $state);
$this->PollChannels(true);
} catch (Throwable $exception) {
$this->setHardwareStatus(3, $key . ': ' . $exception->getMessage());
$this->SetStatus(self::STATUS_WRITE_FAILED);
throw $exception;
}
}
public function SetAnalogOutput(int $channel, float $voltage): void
{
$key = 'AO' . $channel;
$this->assertWritableSelected($key);
if (!is_finite($voltage) || $voltage < 0.0 || $voltage > 10.0) {
throw new InvalidArgumentException('Analoger Ausgang muss zwischen 0 und 10 V liegen.');
}
try {
$calibration = $this->calibrationFor($key);
$raw = CC100ChannelModel::engineeringToRaw($voltage * 1000.0, $calibration);
$this->writeFile($this->ReadPropertyString($key . 'PowerPath'), '0');
$this->writeFile($this->ReadPropertyString($key . 'ValuePath'), (string) $raw);
$this->setValueIfChanged($key, round($voltage, 3));
$this->PollChannels(true);
} catch (Throwable $exception) {
$this->setHardwareStatus(3, $key . ': ' . $exception->getMessage());
$this->SetStatus(self::STATUS_WRITE_FAILED);
throw $exception;
}
}
/**
* @param list<array{
* key: string,
* type: string,
* channel: int,
* name: string,
* active: bool,
* pollInterval: int
* }> $channels
*/
private function synchronizeVariables(array $channels): void
{
$configured = [];
foreach ($channels as $channel) {
$configured[$channel['key']] = $channel;
if (!$channel['active']) {
continue;
}
$definition = CC100ChannelModel::definition($channel['type'], $channel['channel']);
if ($definition['variableType'] === 0) {
$this->RegisterVariableBoolean(
$channel['key'],
$channel['name'],
$definition['profile'],
$definition['position']
);
} else {
$this->RegisterVariableFloat(
$channel['key'],
$channel['name'],
$definition['profile'],
$definition['position']
);
}
if ($definition['writable']) {
$this->EnableAction($channel['key']);
}
}
foreach (CC100ChannelModel::defaultSelection() as $default) {
$key = $default['Typ'] . $default['Kanal'];
if (($configured[$key]['active'] ?? false) === true) {
continue;
}
if (@$this->GetIDForIdent($key) !== false) {
$this->UnregisterVariable($key);
}
}
}
/**
* @param array{key: string, type: string, channel: int} $channel
* @param array<string, int> $fileCache
* @return bool|float
*/
private function readChannel(array $channel, array &$fileCache)
{
$type = $channel['type'];
$number = $channel['channel'];
if ($type === 'DI' || $type === 'DO') {
$property = $type === 'DI' ? 'DigitalInputPath' : 'DigitalOutputPath';
$path = $this->ReadPropertyString($property);
if (!array_key_exists($path, $fileCache)) {
$fileCache[$path] = CC100ChannelModel::parseInteger($this->readFile($path));
}
return CC100ChannelModel::bitState($fileCache[$path], $number);
}
$key = $type . $number;
$raw = CC100ChannelModel::parseInteger($this->readFile($this->pathFor($key)));
$engineering = CC100ChannelModel::rawToEngineering($raw, $this->calibrationFor($key));
if ($type === 'PT') {
return CC100ChannelModel::pt1000Temperature($engineering);
}
return round($engineering / 1000.0, 3);
}
private function pathFor(string $key): string
{
if (str_starts_with($key, 'AI') || str_starts_with($key, 'PT')) {
return $this->ReadPropertyString($key . 'Path');
}
if (str_starts_with($key, 'AO')) {
return $this->ReadPropertyString($key . 'ValuePath');
}
throw new InvalidArgumentException('Kein Einzelpfad fuer ' . $key . '.');
}
/** @return array{x1: float, y1: float, x2: float, y2: float} */
private function calibrationFor(string $key): array
{
$stored = json_decode($this->ReadAttributeString('Calibration'), true);
if (!is_array($stored) || $stored === []) {
$stored = CC100ChannelModel::parseCalibration(
$this->readFile($this->ReadPropertyString('CalibrationPath'))
);
$this->WriteAttributeString(
'Calibration',
json_encode($stored, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
}
if (!isset($stored[$key]) || !is_array($stored[$key])) {
throw new RuntimeException('Kalibrierung fuer ' . $key . ' fehlt.');
}
return $stored[$key];
}
private function assertWritableSelected(string $key): void
{
$channels = CC100ChannelModel::normalizeSelection($this->ReadPropertyString('Kanalauswahl'));
foreach ($channels as $channel) {
if ($channel['key'] !== $key || !$channel['active']) {
continue;
}
$definition = CC100ChannelModel::definition($channel['type'], $channel['channel']);
if ($definition['writable']) {
return;
}
}
throw new RuntimeException($key . ' ist nicht als bedienbarer Kanal aktiviert.');
}
private function readFile(string $path): string
{
if ($path === '' || !is_readable($path)) {
throw new RuntimeException('Datei ist nicht lesbar: ' . $path);
}
$content = @file_get_contents($path);
if ($content === false) {
throw new RuntimeException('Datei konnte nicht gelesen werden: ' . $path);
}
return $content;
}
private function writeFile(string $path, string $value): void
{
if ($path === '') {
throw new RuntimeException('Ausgabepfad ist leer.');
}
$written = @file_put_contents($path, $value);
if ($written === false) {
throw new RuntimeException('Datei konnte nicht geschrieben werden: ' . $path);
}
$this->debug('Hardware schreiben', $path . ' <- ' . $value);
}
/** @param bool|float $value */
private function setValueIfChanged(string $ident, $value): void
{
$variableID = @$this->GetIDForIdent($ident);
if ($variableID === false) {
return;
}
$current = $this->GetValue($ident);
if (is_float($value)) {
if (is_float($current) && abs($current - $value) < 0.0001) {
return;
}
} elseif ($current === $value) {
return;
}
$this->SetValue($ident, $value);
}
private function setHardwareStatus(int $status, string $message): void
{
$this->setValueIfChanged('Hardwarestatus', $status);
$this->setValueIfChanged('Stoertext', $message);
}
/** @param mixed $message */
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);
}
}