diff --git a/CC100Hardware/README.md b/CC100Hardware/README.md new file mode 100644 index 0000000..1a8b011 --- /dev/null +++ b/CC100Hardware/README.md @@ -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. diff --git a/CC100Hardware/form.json b/CC100Hardware/form.json new file mode 100644 index 0000000..f00b96d --- /dev/null +++ b/CC100Hardware/form.json @@ -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" } + ] +} diff --git a/CC100Hardware/libs/CC100ChannelModel.php b/CC100Hardware/libs/CC100ChannelModel.php new file mode 100644 index 0000000..e39318c --- /dev/null +++ b/CC100Hardware/libs/CC100ChannelModel.php @@ -0,0 +1,277 @@ + */ + private const CHANNEL_COUNTS = [ + 'DI' => 8, + 'DO' => 4, + 'AI' => 2, + 'AO' => 2, + 'PT' => 2, + ]; + + /** @var array */ + private const DEFAULT_INTERVALS = [ + 'DI' => 500, + 'DO' => 500, + 'AI' => 5000, + 'AO' => 5000, + 'PT' => 5000, + ]; + + /** + * @return list + */ + 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 + */ + 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 + */ + 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; + } +} diff --git a/CC100Hardware/module.json b/CC100Hardware/module.json new file mode 100644 index 0000000..d43ed90 --- /dev/null +++ b/CC100Hardware/module.json @@ -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": "" +} diff --git a/CC100Hardware/module.php b/CC100Hardware/module.php new file mode 100644 index 0000000..878ff92 --- /dev/null +++ b/CC100Hardware/module.php @@ -0,0 +1,405 @@ +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 $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 $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); + } +} diff --git a/README.md b/README.md index fcab138..2404006 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix ## 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 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) - Virtuelle Batterie -- CC100 Hardware +- CC100 Hardware (implementiert) - Energiediagramm (implementiert) - VGT-Schnittstelle - 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 Module geladen sein: +- `CC100 Hardware` - `Energiediagramm` - `Shelly Modul` - `Verbrauchskostenreport` diff --git a/docs/module/CC100-Hardware/README.md b/docs/module/CC100-Hardware/README.md index 5420027..1a8b011 100644 --- a/docs/module/CC100-Hardware/README.md +++ b/docs/module/CC100-Hardware/README.md @@ -1,29 +1,136 @@ # 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, -Anzeigename, Aktivierung und Abfrageintervall werden je Eintrag festgelegt. +Das Modul bindet die komplette Onboard-I/O des CC100 direkt ueber die von WAGO +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 | -| --- | --- | --- | -| Dynamisch je Kanal | passend / Anzeige oder bedienbar | Nur ausgewählte Kanäle; DO/AO erst nach geprüfter Zuordnung bedienbar. | -| `Hardwarestatus` | Integer / Anzeige | Anschlussstatus. | -| `Stoertext` | String / Anzeige | Zugriffs- oder Messfehler. | +## 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 -| Ident | Typ | Standard / Beschreibung | +| Property | Standard | Bedeutung | | --- | --- | --- | -| `Kanalauswahl` | String/JSON | `[]`; Kanalart, Nummer, Anzeigename, Aktiv und Abfrageintervall in ms je Zeile. | -| `StartverhaltenAusgaenge` | Auswahl | `Lesen/uebernehmen` oder definierte Startwerte schreiben. | -| `StartwerteAusgaenge` | String/JSON | `{}`; nur bei ausdrücklich gewähltem Schreibmodus. | +| '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 | -## 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. -- Nur ausgewählte Kanäle werden angelegt, abgefragt oder bedient. -- CC100-Hardwarepfade, Skalierung und Schreibbarkeit von AI/AO prüfen. -- RS485-Schnittstelle bleibt ein eigener Prüfauftrag. +## 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. diff --git a/docs/module/README.md b/docs/module/README.md index fad1ea2..07a776c 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,13 +1,13 @@ # Modulübersicht Enelix Utils -> Status: Verbrauchskostenreport, Energiediagramm und Shelly Modul sind -> implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert. +> Status: Verbrauchskostenreport, CC100 Hardware, Energiediagramm und Shelly +> Modul sind implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert. | Modul | Herkunft | EMS-Abhängigkeit | | --- | --- | --- | | [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine | | [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 | | [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar | | [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine | diff --git a/docs/testing/README.md b/docs/testing/README.md index 827d15e..c45dbbf 100644 --- a/docs/testing/README.md +++ b/docs/testing/README.md @@ -42,7 +42,7 @@ werden, wenn sie vor dem Lauf nicht existierten. - `single`: genau die als Auswahl übergebenen Module - `affected`: die durch geänderte Pfade ermittelten Module -Verfügbare Module: `Energiediagramm`, `ShellyModul`, +Verfügbare Module: `CC100Hardware`, `Energiediagramm`, `ShellyModul`, `Verbrauchskostenreport`. Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet. diff --git a/tests/CC100HardwareModulstrukturTest.php b/tests/CC100HardwareModulstrukturTest.php new file mode 100644 index 0000000..bfff78c --- /dev/null +++ b/tests/CC100HardwareModulstrukturTest.php @@ -0,0 +1,73 @@ + $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)); + } +} diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index 7ab9325..1ca13cb 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -14,7 +14,7 @@ final class DokumentationsstrukturTest extends TestCase return [ 'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'], 'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'], - 'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'], + 'CC100 Hardware' => ['CC100-Hardware', 'Status: Implementiert'], 'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'], 'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'], 'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'], diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php index 1af28a0..8ea797a 100644 --- a/tests/Symcon/manifest.php +++ b/tests/Symcon/manifest.php @@ -3,6 +3,12 @@ declare(strict_types=1); return [ + 'CC100Hardware' => [ + 'file' => __DIR__ . '/modules/CC100Hardware.php', + 'paths' => [ + 'CC100Hardware/*', + ], + ], 'Energiediagramm' => [ 'file' => __DIR__ . '/modules/Energiediagramm.php', 'paths' => [ diff --git a/tests/Symcon/modules/CC100Hardware.php b/tests/Symcon/modules/CC100Hardware.php new file mode 100644 index 0000000..b2218e8 --- /dev/null +++ b/tests/Symcon/modules/CC100Hardware.php @@ -0,0 +1,118 @@ + 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); + } +}; diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php index 5965c83..f1110bd 100644 --- a/tests/SymconTestContractTest.php +++ b/tests/SymconTestContractTest.php @@ -58,7 +58,7 @@ final class SymconTestContractTest extends TestCase public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void { self::assertSame( - ['Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'], + ['CC100Hardware', 'Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'], $this->resolveAffectedModules(['tests/Symcon/TestContext.php']) ); }