feat(utils): adapt WAGO CC100 hardware module

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