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
+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 [
'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'],
+6
View File
@@ -3,6 +3,12 @@
declare(strict_types=1);
return [
'CC100Hardware' => [
'file' => __DIR__ . '/modules/CC100Hardware.php',
'paths' => [
'CC100Hardware/*',
],
],
'Energiediagramm' => [
'file' => __DIR__ . '/modules/Energiediagramm.php',
'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
{
self::assertSame(
['Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'],
['CC100Hardware', 'Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
);
}