406 lines
14 KiB
PHP
406 lines
14 KiB
PHP
<?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);
|
|
}
|
|
}
|