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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Das Repository befindet sich im Aufbau. Verbrauchskostenreport,
|
Das Repository befindet sich im Aufbau. Verbrauchskostenreport, CC100 Hardware,
|
||||||
Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module
|
Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module
|
||||||
enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert.
|
enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert.
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentier
|
|||||||
|
|
||||||
- Verbrauchskostenreport (implementiert)
|
- Verbrauchskostenreport (implementiert)
|
||||||
- Virtuelle Batterie
|
- Virtuelle Batterie
|
||||||
- CC100 Hardware
|
- CC100 Hardware (implementiert)
|
||||||
- Energiediagramm (implementiert)
|
- Energiediagramm (implementiert)
|
||||||
- VGT-Schnittstelle
|
- VGT-Schnittstelle
|
||||||
- Shelly Modul (implementiert)
|
- Shelly Modul (implementiert)
|
||||||
@@ -59,6 +59,7 @@ Aktualisierungen werden ausschließlich über die IP-Symcon-Modulverwaltung
|
|||||||
bezogen. Nach einem Branchwechsel oder Update müssen alle implementierten
|
bezogen. Nach einem Branchwechsel oder Update müssen alle implementierten
|
||||||
Module geladen sein:
|
Module geladen sein:
|
||||||
|
|
||||||
|
- `CC100 Hardware`
|
||||||
- `Energiediagramm`
|
- `Energiediagramm`
|
||||||
- `Shelly Modul`
|
- `Shelly Modul`
|
||||||
- `Verbrauchskostenreport`
|
- `Verbrauchskostenreport`
|
||||||
|
|||||||
@@ -1,29 +1,136 @@
|
|||||||
# CC100 Hardware
|
# CC100 Hardware
|
||||||
|
|
||||||
> Status: Diskussionsentwurf. Keine EMS-Abhängigkeit.
|
> Status: Implementiert fuer den WAGO Compact Controller 100, Artikel 751-9301,
|
||||||
|
> mit IP-Symcon ab Version 8.0.
|
||||||
|
|
||||||
Stellt nur ausgewählte DI-, DO-, PT-, AI- und AO-Kanäle bereit. Kanal,
|
Das Modul bindet die komplette Onboard-I/O des CC100 direkt ueber die von WAGO
|
||||||
Anzeigename, Aktivierung und Abfrageintervall werden je Eintrag festgelegt.
|
dokumentierten Linux-Schnittstellen an:
|
||||||
|
|
||||||
## Variablen
|
- 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)
|
||||||
|
|
||||||
| Ident | Typ / Zugriff | Beschreibung |
|
## Kanalauswahl
|
||||||
| --- | --- | --- |
|
|
||||||
| Dynamisch je Kanal | passend / Anzeige oder bedienbar | Nur ausgewählte Kanäle; DO/AO erst nach geprüfter Zuordnung bedienbar. |
|
Die Property 'Kanalauswahl' enthaelt alle 18 Kanaele. Pro Zeile werden
|
||||||
| `Hardwarestatus` | Integer / Anzeige | Anschlussstatus. |
|
Aktivierung, Typ, Kanalnummer, Anzeigename und Abfrageintervall festgelegt.
|
||||||
| `Stoertext` | String / Anzeige | Zugriffs- oder Messfehler. |
|
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
|
## Properties
|
||||||
|
|
||||||
| Ident | Typ | Standard / Beschreibung |
|
| Property | Standard | Bedeutung |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `Kanalauswahl` | String/JSON | `[]`; Kanalart, Nummer, Anzeigename, Aktiv und Abfrageintervall in ms je Zeile. |
|
| 'Kanalauswahl' | alle Kanaele inaktiv | Sichtbare und abgefragte Kanaele |
|
||||||
| `StartverhaltenAusgaenge` | Auswahl | `Lesen/uebernehmen` oder definierte Startwerte schreiben. |
|
| 'DigitalInputPath' | WAGO-DI-Sammelpfad | 8-Bit-Eingangswert, LSB ist DI1 |
|
||||||
| `StartwerteAusgaenge` | String/JSON | `{}`; nur bei ausdrücklich gewähltem Schreibmodus. |
|
| '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 |
|
||||||
|
|
||||||
## Verhalten und offene Punkte
|
Die Hardwarepfade sind im Konfigurationsformular unter **Erweiterte
|
||||||
|
Hardwarepfade** sichtbar. Dadurch koennen Firmwarevarianten angepasst werden,
|
||||||
|
ohne den Modulcode zu aendern.
|
||||||
|
|
||||||
- Beim Start werden Ausgänge nicht ungeprüft überschrieben.
|
## Aktualisierung
|
||||||
- Nur ausgewählte Kanäle werden angelegt, abgefragt oder bedient.
|
|
||||||
- CC100-Hardwarepfade, Skalierung und Schreibbarkeit von AI/AO prüfen.
|
Die CC100-Treiber stellen die Onboard-I/O als lesbare beziehungsweise
|
||||||
- RS485-Schnittstelle bleibt ein eigener Prüfauftrag.
|
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.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Modulübersicht Enelix Utils
|
# Modulübersicht Enelix Utils
|
||||||
|
|
||||||
> Status: Verbrauchskostenreport, Energiediagramm und Shelly Modul sind
|
> Status: Verbrauchskostenreport, CC100 Hardware, Energiediagramm und Shelly
|
||||||
> implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert.
|
> Modul sind implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert.
|
||||||
|
|
||||||
| Modul | Herkunft | EMS-Abhängigkeit |
|
| Modul | Herkunft | EMS-Abhängigkeit |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine |
|
| [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine |
|
||||||
| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit |
|
| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit |
|
||||||
| [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW | Keine |
|
| [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW (implementiert) | Keine |
|
||||||
| [Energiediagramm](Energiediagramm/README.md) | Energy_Pie (implementiert) | Keine |
|
| [Energiediagramm](Energiediagramm/README.md) | Energy_Pie (implementiert) | Keine |
|
||||||
| [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar |
|
| [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar |
|
||||||
| [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine |
|
| [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine |
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ werden, wenn sie vor dem Lauf nicht existierten.
|
|||||||
- `single`: genau die als Auswahl übergebenen Module
|
- `single`: genau die als Auswahl übergebenen Module
|
||||||
- `affected`: die durch geänderte Pfade ermittelten Module
|
- `affected`: die durch geänderte Pfade ermittelten Module
|
||||||
|
|
||||||
Verfügbare Module: `Energiediagramm`, `ShellyModul`,
|
Verfügbare Module: `CC100Hardware`, `Energiediagramm`, `ShellyModul`,
|
||||||
`Verbrauchskostenreport`.
|
`Verbrauchskostenreport`.
|
||||||
|
|
||||||
Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet.
|
Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet.
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixUtils\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class CC100HardwareModulstrukturTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testModuleMetadataAndFormAreComplete(): void
|
||||||
|
{
|
||||||
|
$module = json_decode(
|
||||||
|
file_get_contents(__DIR__ . '/../CC100Hardware/module.json'),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
$form = json_decode(
|
||||||
|
file_get_contents(__DIR__ . '/../CC100Hardware/form.json'),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('{D62EA550-68A5-48E2-BF31-1C721AA1BD4E}', $module['id']);
|
||||||
|
self::assertSame('CC100 Hardware', $module['name']);
|
||||||
|
self::assertSame('CC100', $module['prefix']);
|
||||||
|
self::assertNotEmpty($form['elements']);
|
||||||
|
self::assertNotEmpty($form['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testModuleProvidesEveryCc100IoGroupAndConfigurablePolling(): void
|
||||||
|
{
|
||||||
|
$module = file_get_contents(__DIR__ . '/../CC100Hardware/module.php');
|
||||||
|
$form = file_get_contents(__DIR__ . '/../CC100Hardware/form.json');
|
||||||
|
|
||||||
|
self::assertNotFalse($module);
|
||||||
|
self::assertNotFalse($form);
|
||||||
|
foreach ([
|
||||||
|
'DigitalInputPath',
|
||||||
|
'DigitalOutputPath',
|
||||||
|
'AI1Path',
|
||||||
|
'AI2Path',
|
||||||
|
'AO1ValuePath',
|
||||||
|
'AO2ValuePath',
|
||||||
|
'PT1Path',
|
||||||
|
'PT2Path',
|
||||||
|
] as $property) {
|
||||||
|
self::assertStringContainsString($property, $module);
|
||||||
|
self::assertStringContainsString($property, $form);
|
||||||
|
}
|
||||||
|
self::assertStringContainsString('Abfrageintervall', $form);
|
||||||
|
self::assertStringContainsString("RegisterTimer(\n 'PollChannels'", $module);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOutputsAreOnlyWrittenByExplicitActions(): void
|
||||||
|
{
|
||||||
|
$module = file_get_contents(__DIR__ . '/../CC100Hardware/module.php');
|
||||||
|
self::assertNotFalse($module);
|
||||||
|
|
||||||
|
$applyStart = strpos($module, 'public function ApplyChanges');
|
||||||
|
$actionStart = strpos($module, 'public function RequestAction');
|
||||||
|
self::assertNotFalse($applyStart);
|
||||||
|
self::assertNotFalse($actionStart);
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
'writeFile(',
|
||||||
|
substr($module, $applyStart, $actionStart - $applyStart)
|
||||||
|
);
|
||||||
|
self::assertStringNotContainsString('TryWriteFile', $module);
|
||||||
|
self::assertStringNotContainsString('usleep(', $module);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixUtils\Tests;
|
||||||
|
|
||||||
|
use Belevo\EnelixUtils\CC100\CC100ChannelModel;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../CC100Hardware/libs/CC100ChannelModel.php';
|
||||||
|
|
||||||
|
final class CC100HardwareTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDefaultSelectionContainsEveryOnboardChannelExactlyOnce(): void
|
||||||
|
{
|
||||||
|
$selection = CC100ChannelModel::defaultSelection();
|
||||||
|
|
||||||
|
self::assertCount(18, $selection);
|
||||||
|
self::assertCount(
|
||||||
|
18,
|
||||||
|
array_unique(array_map(
|
||||||
|
static fn (array $row): string => $row['Typ'] . $row['Kanal'],
|
||||||
|
$selection
|
||||||
|
))
|
||||||
|
);
|
||||||
|
self::assertSame([], array_filter(
|
||||||
|
$selection,
|
||||||
|
static fn (array $row): bool => $row['Aktiv']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSelectionNormalizesNamesAndIntervals(): void
|
||||||
|
{
|
||||||
|
$selection = CC100ChannelModel::normalizeSelection(json_encode([
|
||||||
|
[
|
||||||
|
'Aktiv' => true,
|
||||||
|
'Typ' => 'di',
|
||||||
|
'Kanal' => 1,
|
||||||
|
'Anzeigename' => '',
|
||||||
|
'Abfrageintervall' => 250,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Aktiv' => true,
|
||||||
|
'Typ' => 'AO',
|
||||||
|
'Kanal' => 2,
|
||||||
|
'Anzeigename' => 'Ventil',
|
||||||
|
'Abfrageintervall' => 5000,
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
self::assertSame('DI1', $selection[0]['key']);
|
||||||
|
self::assertSame('Digitaler Eingang 1', $selection[0]['name']);
|
||||||
|
self::assertSame(250, $selection[0]['pollInterval']);
|
||||||
|
self::assertSame('AO2', $selection[1]['key']);
|
||||||
|
self::assertSame('Ventil', $selection[1]['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDuplicateChannelIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
CC100ChannelModel::normalizeSelection(json_encode([
|
||||||
|
['Typ' => 'DI', 'Kanal' => 1, 'Abfrageintervall' => 500],
|
||||||
|
['Typ' => 'DI', 'Kanal' => 1, 'Abfrageintervall' => 500],
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalibrationSupportsWagoAZeroOneHeaderAndBothDirections(): void
|
||||||
|
{
|
||||||
|
$calibration = CC100ChannelModel::parseCalibration(
|
||||||
|
"AI1 A01\n100 0 1100 10000\n200 0 1200 10000\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertArrayHasKey('AO1', $calibration);
|
||||||
|
self::assertSame(5000.0, CC100ChannelModel::rawToEngineering(600, $calibration['AI1']));
|
||||||
|
self::assertSame(700, CC100ChannelModel::engineeringToRaw(5000, $calibration['AO1']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPt1000CurveAndBitOperationsAreStable(): void
|
||||||
|
{
|
||||||
|
self::assertSame(0.0, CC100ChannelModel::pt1000Temperature(1000.0));
|
||||||
|
self::assertEqualsWithDelta(100.0, CC100ChannelModel::pt1000Temperature(1385.05), 0.1);
|
||||||
|
|
||||||
|
self::assertTrue(CC100ChannelModel::bitState(5, 1));
|
||||||
|
self::assertFalse(CC100ChannelModel::bitState(5, 2));
|
||||||
|
self::assertTrue(CC100ChannelModel::bitState(5, 3));
|
||||||
|
self::assertSame(7, CC100ChannelModel::withBitState(5, 2, true));
|
||||||
|
self::assertSame(4, CC100ChannelModel::withBitState(5, 1, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ final class DokumentationsstrukturTest extends TestCase
|
|||||||
return [
|
return [
|
||||||
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
|
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
|
||||||
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'],
|
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'],
|
||||||
'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'],
|
'CC100 Hardware' => ['CC100-Hardware', 'Status: Implementiert'],
|
||||||
'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'],
|
'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'],
|
||||||
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
|
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
|
||||||
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
|
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'CC100Hardware' => [
|
||||||
|
'file' => __DIR__ . '/modules/CC100Hardware.php',
|
||||||
|
'paths' => [
|
||||||
|
'CC100Hardware/*',
|
||||||
|
],
|
||||||
|
],
|
||||||
'Energiediagramm' => [
|
'Energiediagramm' => [
|
||||||
'file' => __DIR__ . '/modules/Energiediagramm.php',
|
'file' => __DIR__ . '/modules/Energiediagramm.php',
|
||||||
'paths' => [
|
'paths' => [
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Belevo\EnelixUtils\SymconTest\TestContext;
|
||||||
|
|
||||||
|
return static function (TestContext $test): void {
|
||||||
|
$temporaryFiles = [];
|
||||||
|
$createFile = static function (string $content) use (&$temporaryFiles): string {
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'enelix_cc100_');
|
||||||
|
if ($path === false || file_put_contents($path, $content) === false) {
|
||||||
|
throw new RuntimeException('Temporäre CC100-Testdatei konnte nicht erstellt werden.');
|
||||||
|
}
|
||||||
|
$temporaryFiles[] = $path;
|
||||||
|
return $path;
|
||||||
|
};
|
||||||
|
|
||||||
|
$digitalInput = $createFile('5');
|
||||||
|
$digitalOutput = $createFile('0');
|
||||||
|
$calibration = $createFile(
|
||||||
|
"PT1 PT2 AI1 AI2 A01 A02\n"
|
||||||
|
. "1000 1000 2000 1385.05\n"
|
||||||
|
. "1000 1000 2000 1385.05\n"
|
||||||
|
. "100 0 1100 10000\n"
|
||||||
|
. "100 0 1100 10000\n"
|
||||||
|
. "200 0 1200 10000\n"
|
||||||
|
. "200 0 1200 10000\n"
|
||||||
|
);
|
||||||
|
$ai1 = $createFile('600');
|
||||||
|
$ao1 = $createFile('700');
|
||||||
|
$ao1Power = $createFile('1');
|
||||||
|
$pt1 = $createFile('1000');
|
||||||
|
|
||||||
|
$channels = json_encode([
|
||||||
|
[
|
||||||
|
'Aktiv' => true,
|
||||||
|
'Typ' => 'DI',
|
||||||
|
'Kanal' => 1,
|
||||||
|
'Anzeigename' => 'Test Eingang',
|
||||||
|
'Abfrageintervall' => 1000,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Aktiv' => true,
|
||||||
|
'Typ' => 'DO',
|
||||||
|
'Kanal' => 1,
|
||||||
|
'Anzeigename' => 'Test Ausgang',
|
||||||
|
'Abfrageintervall' => 1000,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Aktiv' => true,
|
||||||
|
'Typ' => 'AI',
|
||||||
|
'Kanal' => 1,
|
||||||
|
'Anzeigename' => 'Test Spannung Eingang',
|
||||||
|
'Abfrageintervall' => 1000,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Aktiv' => true,
|
||||||
|
'Typ' => 'AO',
|
||||||
|
'Kanal' => 1,
|
||||||
|
'Anzeigename' => 'Test Spannung Ausgang',
|
||||||
|
'Abfrageintervall' => 1000,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Aktiv' => true,
|
||||||
|
'Typ' => 'PT',
|
||||||
|
'Kanal' => 1,
|
||||||
|
'Anzeigename' => 'Test Temperatur',
|
||||||
|
'Abfrageintervall' => 1000,
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$moduleId = $test->createInstance(
|
||||||
|
'{D62EA550-68A5-48E2-BF31-1C721AA1BD4E}',
|
||||||
|
'CC100 Hardware',
|
||||||
|
[
|
||||||
|
'Kanalauswahl' => $channels,
|
||||||
|
'DigitalInputPath' => $digitalInput,
|
||||||
|
'DigitalOutputPath' => $digitalOutput,
|
||||||
|
'CalibrationPath' => $calibration,
|
||||||
|
'AI1Path' => $ai1,
|
||||||
|
'AO1ValuePath' => $ao1,
|
||||||
|
'AO1PowerPath' => $ao1Power,
|
||||||
|
'PT1Path' => $pt1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$test->runCase('Nur ausgewaehlte Kanaele werden angelegt und gelesen', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($moduleId): void {
|
||||||
|
$test->assertInstanceStatus($moduleId);
|
||||||
|
$test->assertSame(true, GetValue($test->objectByIdent('DI1', $moduleId)));
|
||||||
|
$test->assertSame(false, @IPS_GetObjectIDByIdent('DI2', $moduleId));
|
||||||
|
$test->assertEquals(5.0, (float) GetValue($test->objectByIdent('AI1', $moduleId)), 0.001);
|
||||||
|
$test->assertEquals(0.0, (float) GetValue($test->objectByIdent('PT1', $moduleId)), 0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Digitaler Ausgang erhaelt die anderen Bits', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($moduleId, $digitalOutput): void {
|
||||||
|
file_put_contents($digitalOutput, '4');
|
||||||
|
IPS_RequestAction($moduleId, 'DO1', true);
|
||||||
|
$test->assertSame('5', trim((string) file_get_contents($digitalOutput)));
|
||||||
|
$test->assertSame(true, GetValue($test->objectByIdent('DO1', $moduleId)));
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Analoger Ausgang wird invers kalibriert geschrieben', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($moduleId, $ao1, $ao1Power): void {
|
||||||
|
IPS_RequestAction($moduleId, 'AO1', 2.5);
|
||||||
|
$test->assertSame('0', trim((string) file_get_contents($ao1Power)));
|
||||||
|
$test->assertSame('450', trim((string) file_get_contents($ao1)));
|
||||||
|
$test->assertEquals(2.5, (float) GetValue($test->objectByIdent('AO1', $moduleId)), 0.001);
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($temporaryFiles as $path) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -58,7 +58,7 @@ final class SymconTestContractTest extends TestCase
|
|||||||
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
|
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'],
|
['CC100Hardware', 'Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'],
|
||||||
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
|
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user