feat(utils): adapt WAGO CC100 hardware module
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# CC100 Hardware
|
||||
|
||||
> Status: Implementiert fuer den WAGO Compact Controller 100, Artikel 751-9301,
|
||||
> mit IP-Symcon ab Version 8.0.
|
||||
|
||||
Das Modul bindet die komplette Onboard-I/O des CC100 direkt ueber die von WAGO
|
||||
dokumentierten Linux-Schnittstellen an:
|
||||
|
||||
- 8 digitale Eingaenge (DI)
|
||||
- 4 digitale Ausgaenge (DO)
|
||||
- 2 analoge Eingaenge 0 bis 10 V (AI)
|
||||
- 2 analoge Ausgaenge 0 bis 10 V (AO)
|
||||
- 2 PT1000-Temperatureingaenge (PT)
|
||||
|
||||
## Kanalauswahl
|
||||
|
||||
Die Property 'Kanalauswahl' enthaelt alle 18 Kanaele. Pro Zeile werden
|
||||
Aktivierung, Typ, Kanalnummer, Anzeigename und Abfrageintervall festgelegt.
|
||||
Nur aktive Kanaele werden als IP-Symcon-Variablen angelegt. Wird ein Kanal
|
||||
deaktiviert, entfernt das Modul seine dynamische Variable beim naechsten
|
||||
Uebernehmen der Konfiguration.
|
||||
|
||||
DO- und AO-Variablen sind bedienbar. DI, AI und PT sind reine Messwerte.
|
||||
AI und AO werden in Volt dargestellt, PT in Grad Celsius.
|
||||
|
||||
## Properties
|
||||
|
||||
| Property | Standard | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| 'Kanalauswahl' | alle Kanaele inaktiv | Sichtbare und abgefragte Kanaele |
|
||||
| 'DigitalInputPath' | WAGO-DI-Sammelpfad | 8-Bit-Eingangswert, LSB ist DI1 |
|
||||
| 'DigitalOutputPath' | '/sys/kernel/dout_drv/DOUT_DATA' | 4-Bit-Ausgangswert, LSB ist DO1 |
|
||||
| 'CalibrationPath' | '/etc/calib' | WAGO-Zweipunktkalibrierung |
|
||||
| 'AI1Path', 'AI2Path' | WAGO-IIO-Pfade | Analoge Eingangswerte |
|
||||
| 'AO1ValuePath', 'AO2ValuePath' | WAGO-IIO-Pfade | Analoge Ausgangswerte |
|
||||
| 'AO1PowerPath', 'AO2PowerPath' | WAGO-IIO-Pfade | Freigabe der analogen Ausgaenge |
|
||||
| 'PT1Path', 'PT2Path' | WAGO-IIO-Pfade | PT1000-Rohwerte |
|
||||
| 'Debug' | 'false' | Zusaetzliche Diagnose im Debug-Log |
|
||||
|
||||
Die Hardwarepfade sind im Konfigurationsformular unter **Erweiterte
|
||||
Hardwarepfade** sichtbar. Dadurch koennen Firmwarevarianten angepasst werden,
|
||||
ohne den Modulcode zu aendern.
|
||||
|
||||
## Aktualisierung
|
||||
|
||||
Die CC100-Treiber stellen die Onboard-I/O als lesbare beziehungsweise
|
||||
schreibbare Systemdateien bereit. Fuer diese Dateien ist von WAGO kein
|
||||
zuverlaessiger Aenderungs-Eventkanal dokumentiert. Das Modul verwendet deshalb
|
||||
einen gemeinsamen Timer:
|
||||
|
||||
1. Das kleinste aktive Kanalintervall bestimmt den Timer.
|
||||
2. Pro Lauf werden nur faellige Kanaele verarbeitet.
|
||||
3. DI beziehungsweise DO werden je Lauf nur einmal als Sammelwert gelesen.
|
||||
4. IP-Symcon-Werte werden nur bei einer echten Wertaenderung geschrieben.
|
||||
|
||||
Das Intervall kann pro Kanal zwischen 100 ms und 3 600 000 ms eingestellt
|
||||
werden. Kurze Intervalle erhoehen CPU- und I/O-Last.
|
||||
|
||||
## Kalibrierung
|
||||
|
||||
AI, AO und PT verwenden die individuellen Zweipunktwerte aus '/etc/calib'.
|
||||
Das Modul akzeptiert die WAGO-Bezeichnungen 'AO1'/'AO2' sowie die in der
|
||||
WAGO-Beispieldatei vorkommenden Schreibweisen 'A01'/'A02'.
|
||||
|
||||
- AI-Rohwerte werden kalibriert und von mV nach V umgerechnet.
|
||||
- AO-Sollwerte werden von V nach mV und danach invers in den Rohwert umgerechnet.
|
||||
- PT-Rohwerte werden zuerst in Ohm und anschliessend mit der
|
||||
Callendar-Van-Dusen-Kennlinie in Grad Celsius umgerechnet.
|
||||
|
||||
## Ausgangssicherheit
|
||||
|
||||
'ApplyChanges()' schreibt keine Ausgaenge. Beim Start werden DO und AO nur
|
||||
eingelesen. Ein DO-Schaltbefehl liest zuerst den gesamten Ausgangswert, aendert
|
||||
genau das gewaehlte Bit und erhaelt die anderen Ausgaenge. AO akzeptiert nur
|
||||
Werte von 0 bis 10 V und schreibt zuerst die WAGO-Freigabe, danach den
|
||||
kalibrierten Rohwert.
|
||||
|
||||
Die rekursive Schreibschleife des Enelix-1-Moduls wurde verworfen. Ein
|
||||
fehlgeschlagener Schreibzugriff blockiert IP-Symcon nicht bis zu fuenf Sekunden,
|
||||
sondern liefert sofort Status '203' und einen Stoertext.
|
||||
|
||||
## Variablen
|
||||
|
||||
| Ident | Typ | Zugriff |
|
||||
| --- | --- | --- |
|
||||
| 'DI1' bis 'DI8' | Boolean | Anzeige, nur wenn aktiviert |
|
||||
| 'DO1' bis 'DO4' | Boolean | Bedienbar, nur wenn aktiviert |
|
||||
| 'AI1', 'AI2' | Float / V | Anzeige, nur wenn aktiviert |
|
||||
| 'AO1', 'AO2' | Float / V | Bedienbar, nur wenn aktiviert |
|
||||
| 'PT1', 'PT2' | Float / Grad Celsius | Anzeige, nur wenn aktiviert |
|
||||
| 'Hardwarestatus' | Integer | '0' unkonfiguriert, '1' bereit, '2' Lesefehler, '3' Fehler |
|
||||
| 'Stoertext' | String | Letzter zusammengefasster Fehler |
|
||||
|
||||
## Migration aus Enelix 1
|
||||
|
||||
### Unveraendert uebernommen
|
||||
|
||||
- vier DO, acht DI und zwei PT-Kanaele
|
||||
- Bitreihenfolge mit DI1 beziehungsweise DO1 als LSB
|
||||
- die vorhandenen WAGO-Systempfade als Standardwerte
|
||||
|
||||
### Angepasst
|
||||
|
||||
- getrennte feste Timer durch einen gruppierten, kanalspezifischen Poller ersetzt
|
||||
- DO-Aktionen lesen den Sammelwert und aendern nur das gewaehlte Bit
|
||||
- PT-Auswertung verwendet die individuelle Kalibrierung und Kennlinie
|
||||
|
||||
### Neu implementiert
|
||||
|
||||
- beide AI- und AO-Kanaele
|
||||
- frei waehlbare Kanaele und dynamische Variablen
|
||||
- konfigurierbare Intervalle und ueberschreibbare Hardwarepfade
|
||||
- Status-, Fehler- und Debugdiagnose
|
||||
|
||||
### Verworfen
|
||||
|
||||
- rekursives Schreiben mit bis zu fuenf Sekunden Blockierzeit
|
||||
- zyklisches Neuschreiben aller DO ohne neuen Schaltbefehl
|
||||
- feste PT-Rohwertbereiche und nicht kalibrierte Skalierungsfaktoren
|
||||
|
||||
Das alte Modul kann nicht in-place aktualisiert werden. Fuer die Umstellung:
|
||||
|
||||
1. Vorhandene Ausgangszustaende und verwendete Kanaele dokumentieren.
|
||||
2. Eine neue Instanz 'CC100 Hardware' anlegen.
|
||||
3. Benoetigte Kanaele aktivieren und Anzeigenamen vergeben.
|
||||
4. Hardwarepfade und Kalibrierdatei pruefen.
|
||||
5. Zuerst nur Eingaenge testen.
|
||||
6. Ausgaenge einzeln und unter Aufsicht freigeben.
|
||||
|
||||
## RS-485
|
||||
|
||||
RS-485 gehoert nicht zur Onboard-I/O-Abbildung dieses Moduls. Der CC100 stellt
|
||||
die Schnittstelle als '/dev/ttySTM1' bereit. Protokoll, Teilnehmeradressen,
|
||||
Baudrate, Paritaet, Registerbereiche und Zeitvorgaben gehoeren in eine separate
|
||||
serielle beziehungsweise Modbus-Instanz. So bleibt die I/O-Abfrage unabhaengig
|
||||
von langsamen oder fehlerhaften Busteilnehmern.
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "WAGO CC100 751-9301: Nur aktivierte Kanaele werden als Variablen angelegt."
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "Kanalauswahl",
|
||||
"caption": "Onboard-I/O",
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"sortable": false,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "Aktiv",
|
||||
"name": "Aktiv",
|
||||
"width": "10%",
|
||||
"add": false,
|
||||
"edit": {
|
||||
"type": "CheckBox"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Typ",
|
||||
"name": "Typ",
|
||||
"width": "12%",
|
||||
"add": "DI",
|
||||
"edit": {
|
||||
"type": "Select",
|
||||
"options": [
|
||||
{ "caption": "Digitaler Eingang", "value": "DI" },
|
||||
{ "caption": "Digitaler Ausgang", "value": "DO" },
|
||||
{ "caption": "Analoger Eingang", "value": "AI" },
|
||||
{ "caption": "Analoger Ausgang", "value": "AO" },
|
||||
{ "caption": "PT1000", "value": "PT" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Kanal",
|
||||
"name": "Kanal",
|
||||
"width": "10%",
|
||||
"add": 1,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"minimum": 1,
|
||||
"maximum": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Anzeigename",
|
||||
"name": "Anzeigename",
|
||||
"width": "38%",
|
||||
"add": "",
|
||||
"edit": {
|
||||
"type": "ValidationTextBox"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Polling (ms)",
|
||||
"name": "Abfrageintervall",
|
||||
"width": "20%",
|
||||
"add": 1000,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"minimum": 100,
|
||||
"maximum": 3600000
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Erweiterte Hardwarepfade",
|
||||
"expanded": false,
|
||||
"items": [
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "DigitalInputPath",
|
||||
"caption": "Digitale Eingaenge"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "DigitalOutputPath",
|
||||
"caption": "Digitale Ausgaenge"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "CalibrationPath",
|
||||
"caption": "Kalibrierdatei"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "AI1Path",
|
||||
"caption": "AI1 Rohwert"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "AI2Path",
|
||||
"caption": "AI2 Rohwert"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "AO1ValuePath",
|
||||
"caption": "AO1 Rohwert"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "AO1PowerPath",
|
||||
"caption": "AO1 Freigabe"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "AO2ValuePath",
|
||||
"caption": "AO2 Rohwert"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "AO2PowerPath",
|
||||
"caption": "AO2 Freigabe"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "PT1Path",
|
||||
"caption": "PT1 Rohwert"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "PT2Path",
|
||||
"caption": "PT2 Rohwert"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "Debug",
|
||||
"caption": "Debug-Logging aktivieren"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Button",
|
||||
"caption": "Kanaele jetzt einlesen",
|
||||
"onClick": "IPS_RequestAction($id, 'Poll', true);"
|
||||
}
|
||||
],
|
||||
"status": [
|
||||
{ "code": 201, "icon": "error", "caption": "Konfiguration ungueltig" },
|
||||
{ "code": 202, "icon": "error", "caption": "Hardware nicht lesbar" },
|
||||
{ "code": 203, "icon": "error", "caption": "Hardware konnte nicht geschrieben werden" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixUtils\CC100;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
final class CC100ChannelModel
|
||||
{
|
||||
/** @var array<string, int> */
|
||||
private const CHANNEL_COUNTS = [
|
||||
'DI' => 8,
|
||||
'DO' => 4,
|
||||
'AI' => 2,
|
||||
'AO' => 2,
|
||||
'PT' => 2,
|
||||
];
|
||||
|
||||
/** @var array<string, int> */
|
||||
private const DEFAULT_INTERVALS = [
|
||||
'DI' => 500,
|
||||
'DO' => 500,
|
||||
'AI' => 5000,
|
||||
'AO' => 5000,
|
||||
'PT' => 5000,
|
||||
];
|
||||
|
||||
/**
|
||||
* @return list<array{Aktiv: bool, Typ: string, Kanal: int, Anzeigename: string, Abfrageintervall: int}>
|
||||
*/
|
||||
public static function defaultSelection(): array
|
||||
{
|
||||
$selection = [];
|
||||
foreach (self::CHANNEL_COUNTS as $type => $count) {
|
||||
for ($channel = 1; $channel <= $count; $channel++) {
|
||||
$selection[] = [
|
||||
'Aktiv' => false,
|
||||
'Typ' => $type,
|
||||
'Kanal' => $channel,
|
||||
'Anzeigename' => self::defaultName($type, $channel),
|
||||
'Abfrageintervall' => self::DEFAULT_INTERVALS[$type],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
* type: string,
|
||||
* channel: int,
|
||||
* name: string,
|
||||
* active: bool,
|
||||
* pollInterval: int
|
||||
* }>
|
||||
*/
|
||||
public static function normalizeSelection(string $json): array
|
||||
{
|
||||
try {
|
||||
$rows = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $exception) {
|
||||
throw new InvalidArgumentException('Kanalauswahl ist kein gueltiges JSON.', 0, $exception);
|
||||
}
|
||||
|
||||
if (!is_array($rows)) {
|
||||
throw new InvalidArgumentException('Kanalauswahl muss eine Liste sein.');
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$keys = [];
|
||||
foreach ($rows as $index => $row) {
|
||||
if (!is_array($row)) {
|
||||
throw new InvalidArgumentException('Kanalzeile ' . ($index + 1) . ' ist ungueltig.');
|
||||
}
|
||||
|
||||
$type = strtoupper(trim((string) ($row['Typ'] ?? '')));
|
||||
$channel = filter_var($row['Kanal'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($channel === false || !isset(self::CHANNEL_COUNTS[$type])) {
|
||||
throw new InvalidArgumentException('Kanalzeile ' . ($index + 1) . ' hat einen ungueltigen Typ oder Kanal.');
|
||||
}
|
||||
if ($channel < 1 || $channel > self::CHANNEL_COUNTS[$type]) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf('%s erlaubt nur die Kanaele 1 bis %d.', $type, self::CHANNEL_COUNTS[$type])
|
||||
);
|
||||
}
|
||||
|
||||
$key = $type . $channel;
|
||||
if (isset($keys[$key])) {
|
||||
throw new InvalidArgumentException('Kanal ' . $key . ' ist doppelt konfiguriert.');
|
||||
}
|
||||
$keys[$key] = true;
|
||||
|
||||
$name = trim((string) ($row['Anzeigename'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = self::defaultName($type, $channel);
|
||||
}
|
||||
if (strlen($name) > 80) {
|
||||
throw new InvalidArgumentException('Der Anzeigename von ' . $key . ' ist zu lang.');
|
||||
}
|
||||
|
||||
$pollInterval = filter_var($row['Abfrageintervall'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($pollInterval === false || $pollInterval < 100 || $pollInterval > 3600000) {
|
||||
throw new InvalidArgumentException(
|
||||
'Das Abfrageintervall von ' . $key . ' muss zwischen 100 und 3600000 ms liegen.'
|
||||
);
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'key' => $key,
|
||||
'type' => $type,
|
||||
'channel' => $channel,
|
||||
'name' => $name,
|
||||
'active' => (bool) ($row['Aktiv'] ?? false),
|
||||
'pollInterval' => $pollInterval,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variableType: int, profile: string, writable: bool, position: int}
|
||||
*/
|
||||
public static function definition(string $type, int $channel): array
|
||||
{
|
||||
$type = strtoupper($type);
|
||||
if (!isset(self::CHANNEL_COUNTS[$type]) || $channel < 1 || $channel > self::CHANNEL_COUNTS[$type]) {
|
||||
throw new InvalidArgumentException('Unbekannter CC100-Kanal.');
|
||||
}
|
||||
|
||||
$offsets = ['DI' => 100, 'DO' => 200, 'AI' => 300, 'AO' => 400, 'PT' => 500];
|
||||
|
||||
return [
|
||||
'variableType' => in_array($type, ['DI', 'DO'], true) ? 0 : 2,
|
||||
'profile' => match ($type) {
|
||||
'DO' => '~Switch',
|
||||
'AI', 'AO' => '~Volt',
|
||||
'PT' => '~Temperature',
|
||||
default => '',
|
||||
},
|
||||
'writable' => in_array($type, ['DO', 'AO'], true),
|
||||
'position' => $offsets[$type] + $channel,
|
||||
];
|
||||
}
|
||||
|
||||
public static function defaultName(string $type, int $channel): string
|
||||
{
|
||||
return match ($type) {
|
||||
'DI' => 'Digitaler Eingang ' . $channel,
|
||||
'DO' => 'Digitaler Ausgang ' . $channel,
|
||||
'AI' => 'Analoger Eingang ' . $channel,
|
||||
'AO' => 'Analoger Ausgang ' . $channel,
|
||||
'PT' => 'PT1000 Temperatur ' . $channel,
|
||||
default => $type . $channel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{x1: float, y1: float, x2: float, y2: float}>
|
||||
*/
|
||||
public static function parseCalibration(string $content): array
|
||||
{
|
||||
$lines = preg_split('/\R/', trim($content)) ?: [];
|
||||
$lines = array_values(array_filter(array_map('trim', $lines), static fn (string $line): bool => $line !== ''));
|
||||
if (count($lines) < 2) {
|
||||
throw new InvalidArgumentException('Kalibrierdatei ist leer oder unvollstaendig.');
|
||||
}
|
||||
|
||||
$headers = preg_split('/\s+/', array_shift($lines)) ?: [];
|
||||
$calibration = [];
|
||||
foreach ($headers as $index => $header) {
|
||||
if (!isset($lines[$index])) {
|
||||
throw new InvalidArgumentException('Kalibrierwert fuer ' . $header . ' fehlt.');
|
||||
}
|
||||
|
||||
$key = strtoupper($header);
|
||||
if ($key === 'A01') {
|
||||
$key = 'AO1';
|
||||
} elseif ($key === 'A02') {
|
||||
$key = 'AO2';
|
||||
}
|
||||
|
||||
$values = preg_split('/\s+/', $lines[$index]) ?: [];
|
||||
if (count($values) !== 4 || count(array_filter($values, 'is_numeric')) !== 4) {
|
||||
throw new InvalidArgumentException('Kalibrierwert fuer ' . $key . ' ist ungueltig.');
|
||||
}
|
||||
|
||||
[$x1, $y1, $x2, $y2] = array_map('floatval', $values);
|
||||
if ($x1 === $x2 || $y1 === $y2) {
|
||||
throw new InvalidArgumentException('Kalibrierpunkte fuer ' . $key . ' sind nicht eindeutig.');
|
||||
}
|
||||
$calibration[$key] = compact('x1', 'y1', 'x2', 'y2');
|
||||
}
|
||||
|
||||
return $calibration;
|
||||
}
|
||||
|
||||
/** @param array{x1: float, y1: float, x2: float, y2: float} $calibration */
|
||||
public static function rawToEngineering(float $raw, array $calibration): float
|
||||
{
|
||||
return (($calibration['y2'] - $calibration['y1']) / ($calibration['x2'] - $calibration['x1']))
|
||||
* ($raw - $calibration['x1'])
|
||||
+ $calibration['y1'];
|
||||
}
|
||||
|
||||
/** @param array{x1: float, y1: float, x2: float, y2: float} $calibration */
|
||||
public static function engineeringToRaw(float $value, array $calibration): int
|
||||
{
|
||||
$raw = (($calibration['x2'] - $calibration['x1']) / ($calibration['y2'] - $calibration['y1']))
|
||||
* ($value - $calibration['y1'])
|
||||
+ $calibration['x1'];
|
||||
|
||||
return (int) round($raw);
|
||||
}
|
||||
|
||||
public static function pt1000Temperature(float $resistance): float
|
||||
{
|
||||
if (!is_finite($resistance) || $resistance < 760.0 || $resistance > 2300.0) {
|
||||
throw new InvalidArgumentException('PT1000-Widerstand liegt ausserhalb des unterstuetzten Bereichs.');
|
||||
}
|
||||
|
||||
$low = -60.0;
|
||||
$high = 350.0;
|
||||
for ($iteration = 0; $iteration < 50; $iteration++) {
|
||||
$middle = ($low + $high) / 2.0;
|
||||
if (self::pt1000Resistance($middle) < $resistance) {
|
||||
$low = $middle;
|
||||
} else {
|
||||
$high = $middle;
|
||||
}
|
||||
}
|
||||
|
||||
return round(($low + $high) / 2.0, 1);
|
||||
}
|
||||
|
||||
public static function parseInteger(string $content): int
|
||||
{
|
||||
$value = trim($content);
|
||||
if (!preg_match('/^-?\d+$/', $value)) {
|
||||
throw new RuntimeException('Hardwarewert ist keine ganze Zahl.');
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
public static function bitState(int $aggregate, int $channel): bool
|
||||
{
|
||||
if ($channel < 1 || $channel > 8) {
|
||||
throw new InvalidArgumentException('Bitkanal muss zwischen 1 und 8 liegen.');
|
||||
}
|
||||
|
||||
return ($aggregate & (1 << ($channel - 1))) !== 0;
|
||||
}
|
||||
|
||||
public static function withBitState(int $aggregate, int $channel, bool $state): int
|
||||
{
|
||||
$mask = 1 << ($channel - 1);
|
||||
return $state ? ($aggregate | $mask) : ($aggregate & ~$mask);
|
||||
}
|
||||
|
||||
private static function pt1000Resistance(float $temperature): float
|
||||
{
|
||||
$a = 3.9083e-3;
|
||||
$b = -5.775e-7;
|
||||
$c = -4.183e-12;
|
||||
$factor = 1.0 + ($a * $temperature) + ($b * $temperature * $temperature);
|
||||
if ($temperature < 0.0) {
|
||||
$factor += $c * ($temperature - 100.0) * $temperature * $temperature * $temperature;
|
||||
}
|
||||
|
||||
return 1000.0 * $factor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "{D62EA550-68A5-48E2-BF31-1C721AA1BD4E}",
|
||||
"name": "CC100 Hardware",
|
||||
"type": 3,
|
||||
"vendor": "Belevo AG",
|
||||
"aliases": [
|
||||
"WAGO CC100 Hardware"
|
||||
],
|
||||
"parentRequirements": [],
|
||||
"childRequirements": [],
|
||||
"implemented": [],
|
||||
"prefix": "CC100",
|
||||
"url": ""
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user