DOCX
rvdbreemen/OTGW-firmware
A skill your agent uses whenever the user wants to create, read, edit, or manipulate Word documents (.docx files).
Exercise the PLC's actual behaviour on real hardware - lights, pushbuttons, covers, dimmers and the HVAC chain - by commanding it over MQTT and asserting the result on the broker and in the running…
$ npx skills add MichielVanwelsenaere/HomeAutomation.CoDeSys3 --skill test-plc-logic -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install MichielVanwelsenaere/HomeAutomation.CoDeSys3 test-plc-logic --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/test-plc-logic .claude/skills/test-plc-logic && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "test-plc-logic" agent skill from https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3/tree/master/.claude/skills/test-plc-logic into .claude/skills/test-plc-logic/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "test-plc-logic", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3/tree/master/.claude/skills/test-plc-logicType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add MichielVanwelsenaere/HomeAutomation.CoDeSys3 --skill test-plc-logic -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install MichielVanwelsenaere/HomeAutomation.CoDeSys3 test-plc-logic --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.claude/skills/test-plc-logic .agents/skills/test-plc-logic && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "test-plc-logic" agent skill from https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3/tree/master/.claude/skills/test-plc-logic into .agents/skills/test-plc-logic/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "test-plc-logic", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add MichielVanwelsenaere/HomeAutomation.CoDeSys3 --skill test-plc-logic -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install MichielVanwelsenaere/HomeAutomation.CoDeSys3 test-plc-logic --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.claude/skills/test-plc-logic .cursor/skills/test-plc-logic && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "test-plc-logic" agent skill from https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3/tree/master/.claude/skills/test-plc-logic into .cursor/skills/test-plc-logic/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "test-plc-logic", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3.git --path .claude/skills/test-plc-logic--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add MichielVanwelsenaere/HomeAutomation.CoDeSys3 --skill test-plc-logic -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install MichielVanwelsenaere/HomeAutomation.CoDeSys3 test-plc-logic --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.claude/skills/test-plc-logic .gemini/skills/test-plc-logic && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "test-plc-logic" agent skill from https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3/tree/master/.claude/skills/test-plc-logic into .gemini/skills/test-plc-logic/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "test-plc-logic", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install MichielVanwelsenaere/HomeAutomation.CoDeSys3 test-plc-logicInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add MichielVanwelsenaere/HomeAutomation.CoDeSys3 --skill test-plc-logic -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3.git skills-src && mkdir -p .github/skills && cp -r skills-src/.claude/skills/test-plc-logic .github/skills/test-plc-logic && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "test-plc-logic" agent skill from https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3/tree/master/.claude/skills/test-plc-logic into .github/skills/test-plc-logic/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "test-plc-logic", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add MichielVanwelsenaere/HomeAutomation.CoDeSys3 --skill test-plc-logic -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install MichielVanwelsenaere/HomeAutomation.CoDeSys3 test-plc-logic --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.claude/skills/test-plc-logic .opencode/skills/test-plc-logic && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "test-plc-logic" agent skill from https://github.com/MichielVanwelsenaere/HomeAutomation.CoDeSys3/tree/master/.claude/skills/test-plc-logic into .opencode/skills/test-plc-logic/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "test-plc-logic", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
test-plc-logicExercise the PLC's actual behaviour on real hardware - lights, pushbuttons, covers, dimmers and the HVAC chain - by commanding it over MQTT and asserting the result on the broker and in the running…
Test Plc Logic is an agent skill from MichielVanwelsenaere/HomeAutomation.CoDeSys3. Exercise the PLC's actual behaviour on real hardware - lights, pushbuttons, covers, dimmers and the HVAC chain - by commanding it over MQTT and asserting the result on the broker and in the running application. Use after a download, when asked whether some logic still works, when a refactor touched behaviour the compiler cannot check, or when a Home Assistant entity is not doing what it should.
Its SKILL.md is about 8.9k tokens, which your agent loads only when the skill is triggered. The skill folder holds 7 other files (for example `specs/assert-idle.json`, `specs/cover-position.json` and `specs/hvac-fast-chain.json`).
It works with Home Assistant. The repository describes itself as: Home Automation system build in CoDeSys 3 with MQTT communication to any third party Home Automation software. The licence is MIT.
5 steps, taken from the step headings in SKILL.md.
Read from SKILL.md and the folder at commit 924c3da. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
No scripts in the folder and no shell commands in SKILL.md (its code samples are powershell and json).
From the folder's file list and the shell code blocks in SKILL.md.
No URLs in SKILL.md.
From URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Test Plc Logic loads about 8.9k tokens when it runs. Until then it costs about 103 tokens; SKILL.md has 4,766 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from MichielVanwelsenaere/HomeAutomation.CoDeSys3 at commit 924c3da, republished under its MIT licence (© MichielVanwelsenaere). 4,766 words, ~8,888 tokens.
.claude/skills/test-plc-logic/SKILL.md (or your agent's skills folder). This skill also uses 6 other files; get the full folder from GitHub.The compiler is the only automated gate this project has. It cannot tell you whether pressing a button turns on a light, or whether a thermostat opens a valve — CODESYS simulation cannot run this project at all, so behaviour is only observable on a real PFC.
Two ways in, and the difference matters:
| What it proves | Use it for | |
|---|---|---|
MQTT — mosquitto_pub to Devices/PLC/Lab/In/... | The whole path a user or Home Assistant takes: broker → subscription → callback → logic → publish | Anything reachable from Home Assistant. Prefer this. |
Online writes — write in a download spec | Only the logic. Bypasses MQTT entirely. | Inputs MQTT cannot reach: a hardware input, a sensor-health flag |
Prefer MQTT. A test that only writes variables will pass while the subscription is broken, which is a failure mode this project has actually had.
./tools/ai/codesys.ps1 doctor # mosquitto_sub present?
(Test-NetConnection 10.101.1.232 -Port 11740).TcpTestSucceeded # runtime up?
./tools/ai/Mqtt-Snapshot.ps1 -Watch -Seconds 12 -Topics 'Devices/PLC/Lab/availability'availability must publish online live, and it has to be -Watch that asks.
The birth message is published with MqttRetain := FALSE while the LWT is retained,
so a retained snapshot of that topic reads offline whatever the PLC is doing — it
is the last will the broker fired when the client last dropped, not a status. A
healthy bench shows online about every five seconds; nothing in twelve means the
application is not running or not connected. If that is silent, or port 11740 is
closed while ping succeeds, the runtime is down — not your credentials, and not the
network. On the bench unit that usually means the two-hour demo licence expired;
it needs a restart, and a login failure mid-download is a symptom of it happening
during the download rather than a cause to go hunting for passwords.
Take a baseline before changing anything, so you can diff at the end:
./tools/ai/Mqtt-Snapshot.ps1 -Out .ai/mqtt/before.txtRetained snapshots show state; they cannot show an event that was published and superseded. To watch live traffic while you command something:
./tools/ai/Mqtt-Snapshot.ps1 -Watch -Seconds 30Run that in one shell (or background it) and publish from another. Pushbutton events in particular are only visible this way.
Topic roots come from GVL_MQTT (MqttMain + MqttType + MqttDevice), so
everything below assumes Devices/PLC/Lab/. Change MqttDevice and it all shifts.
$mp = 'C:\Program Files\mosquitto\mosquitto_pub.exe'
$B = '10.101.1.11'
function Cmd($topic, $payload) { & $mp -h $B -t "Devices/PLC/Lab/In/$topic" -m $payload -q 2 }| What | Command topic (under In/) | Payload | State topic (under Out/) |
|---|---|---|---|
| Binary light | DigitalOutputs/fbDoBin001 | TRUE / FALSE | DigitalOutputs/fbDoBin001 |
| Bistable light | DigitalOutputs/fbDoBistable001 | TRUE / FALSE | DigitalOutputs/fbDoBistable001 |
| Cover | Covers/fbDoCover001 | OPEN / STOP / CLOSE | Covers/fbDoCover001 |
| Cover with position | Covers/fbDoCover002 | OPEN / STOP / CLOSE | Covers/fbDoCover002 |
| ... its position | Covers/fbDoCover002/POSITION | 0..100 | Covers/fbDoCover002/POSITION |
| Dimmer | Dimmers/fbAoDimmer001/... | see the block's page | Dimmers/fbAoDimmer001/OUT, /Q |
| Thermostat mode | HVAC/fbThermostat2/MODE | off / heat / auto | HVAC/fbThermostat2/MODE |
| Thermostat setpoint | HVAC/fbThermostat2/DESIRED_TEMP | e.g. 22 | HVAC/fbThermostat2/DESIRED_TEMP |
Two things the thermostat does that will confuse you if you do not expect them:
MIN_TEMP..MAX_TEMP (17..24 on this project) and
the clamped value is echoed back. Publishing 30 and reading back 24.0 is
correct behaviour, not a bug.IS_CC against 0123456789.. A
payload of 22.0 C is silently ignored — no error anywhere.Reading the broker proves what the outside world sees. Reading the application
proves why. Do both: a download spec's expect fails the run on mismatch, which
is what makes this a test rather than a look around.
./tools/ai/codesys.ps1 download -Force -Ip 10.101.1.232 -Spec .ai/edits/<name>.jsondownload re-downloads and restarts the application, so use it to arrive at a
known state. To assert against an application that is already running without
disturbing it, keep the spec to read and expect only — no write — and note
that the download still restarts it. There is no attach-only task; if you need one,
that is a codesys_task.py addition, not a workaround.
Spec shape (see tools/ai/codesys_task.py run_steps):
{"steps": [
{"label": "why this step exists",
"write": {"PRG_HVAC.fbThermostat2.DESIRED_TEMP": "22"},
"delay_ms": 2000,
"expect": {"PRG_HVAC.fbPump2Collector.VALVE[1]": "TRUE"},
"read": ["PRG_HVAC.fbPump2Collector.PUMP"]}
]}expect compares typed literals. read_value returns UDINT#0, INT#8,
TIME#20s, BYTE#1, 'a string' — not 0, 8, 20s, 16#01. Write the
expectation the way the PLC spells it, or the step fails for the wrong reason.
Two that catch people: a BYTE comes back decimal (BYTE#1), not as the hex
you probably wrote it as in the declaration; and an enum comes back fully
qualified (E_RS485_EASTRON_SDM_DEVICE.SDM220). When in doubt put the variable in
read first, run once, and copy the spelling out of the report into expect.
write and expect do not use the same spelling for an enum. A write takes
the ordinal — "2" for E_HVAC_MODE.heat — while the read back is qualified,
so one variable needs two spellings in the same step:
{"write": {"PRG_HVAC.fbThermostat2.eHvacMode": "2"},
"expect": {"PRG_HVAC.fbThermostat2.eHvacMode": "E_HVAC_MODE.heat"}}Writing the qualified name fails the whole step with
'E_HVAC_MODE#E_HVAC_MODE.heat' is not a valid Integer — loud, at least. Get the
ordinals from the export rather than counting the declaration: they may be
explicit.
delay_ms is a floor, not the elapsed time. Every read and every expect
is a round trip to the PLC and costs real time on top of it, so a step with ten
assertions adds about a second of its own. Do not put an assertion within a
second or two of a timer edge — the interlock spec first did, against a 5 s
ValveCycleTime, and drifted past it in a run where the code was perfect. Leave
several seconds of margin on both sides of every edge you assert.
And force the state you are about to drive, not just the state around it.
eHvacMode and rDesiredTemp are PERSISTENT and survive a download's cold reset,
so a thermostat can arrive already demanding. Setting the other thermostats off
is not enough: the one under test has to be put in a known state too, before the
sensor is made healthy, or the chain starts before the spec thinks it did.
Finish with a diff, which catches damage you were not looking for:
./tools/ai/Mqtt-Snapshot.ps1 -Out .ai/mqtt/after.txt
./tools/ai/Mqtt-Snapshot.ps1 -Diff .ai/mqtt/before.txt,.ai/mqtt/after.txtIDENTICAL after a pure refactor is the strongest result available here. After a
behavioural test, expect exactly the topics you touched to have changed and
nothing else — an unexpected entry in GONE means a Home Assistant entity just
lost its discovery config.
The straightforward one, and worth running first because it proves the whole MQTT path end to end before you debug anything harder.
Cmd 'DigitalOutputs/fbDoBin001' 'TRUE'Out/DigitalOutputs/fbDoBin001 should read TRUE.FALSE.fbDoBistable001.Assert in the same run that the output really moved, not just the topic:
{"expect": {"PRG_MAIN.fbDoBin001.OUT": "TRUE"}}If the state topic never changes: the block is not subscribed. Check
InitMqttDone on the instance, and that its body is being called cyclically —
self-wired blocks wire themselves on their first cyclic call, so an instance whose
body never runs is silently absent from Home Assistant.
fbDoBistable001 will look broken on a bench and is not. It drives an
impulse relay: OUT is a short pulse to the coil (OUT := HoldTimer.Q) and the
state it publishes is FEEDBACK — the relay's own contact, read back from an input.
With no relay wired, pulsing the coil changes nothing observable, so the state topic
stays FALSE however many commands you send. To prove the subscription works here
you have to read MqttHighRequest or the hold timer inside the block; the broker
cannot tell you. Do not record this as a failure without saying which half was
tested.
Both of these announce as light, not switch. If Home Assistant shows
switch. entities, EntityType was lost on the declaration; the retained
homeassistant/light/..._fbDoBin001/config will have been orphaned.
Pushbuttons are inputs. There is nothing to command: the block publishes when the physical button changes, and the events are not retained, so a snapshot will never show them. This suite needs either a finger or a forced input.
With hardware: start -Watch, press the button on the 750-440 module, and
confirm events appear under
Devices/PLC/Lab/Out/DigitalInputs/Pushbuttons/fbDiPb001.
Without: force the digital input in a spec and read the block's outputs. The pushbutton block distinguishes a short press from a long one, so hold the input across a delay long enough to cross that threshold:
{"steps": [
{"write": {"DI_001": "TRUE"}, "delay_ms": 150,
"read": ["PRG_MAIN.fbDiPb001.P_SHORT", "PRG_MAIN.fbDiPb001.P_LONG"]},
{"write": {"DI_001": "FALSE"}, "delay_ms": 200,
"read": ["PRG_MAIN.fbDiPb001.P_SHORT"]}
]}Substitute the real input variable — check what fbDiPb001 is actually wired to
in READ_PUSHBUTTONS rather than trusting DI_001 here.
A written input may be overwritten. If the program assigns that variable every cycle from the bus, a one-shot write lasts one cycle. If the value will not stick, say so and press the button instead — do not report a pass you did not get.
Note that fbDiPb001.P_LONG drives the cover in MOVE_COVERS, so a long press
here is also a cover test.
specs/cover-position.json is the whole test: nine steps, OPEN → STOP → CLOSE
→ end stop → 60%, asserting the coils as well as the block outputs at every step.
./tools/ai/codesys.ps1 download -Force -Address 00E8 -Spec .claude/skills/test-plc-logic/specs/cover-position.jsonfbDoCover002.MU and MD are wired to DO_005 and DO_006 on the second
750-540, so the LEDs on that module are the test you can watch from across the room.
Read both the coil and the block output, never just one: the two disagreeing is the
failure worth catching — a block that believes it is driving while nothing reaches
the module. That has happened here twice, once because two tasks were writing the
same coil and once because a library block was simulating a position without ever
energising an output.
The last four steps are the ones worth keeping: they lie to the block — write
PositionReal := 80.0 while the cover sits at the bottom — and then assert that a
full CLOSE still drives for the whole travel time and finishes referenced at 0.
That is the drift case a time-based cover cannot detect for itself, and the reason
a full command must ignore the estimate: a run computed from a wrong estimate ends
in the wrong place and leaves the estimate wrong. Healing only T_EndStop / T_Travel
per command — 10% at the defaults — is what the block did before, and it looked
fine on every test that did not lie to it first.
Step one also asserts PublishedPosition = BYTE#1 at rest, not 0: an unreferenced
0 makes Home Assistant disable the close button, which is the command that would
have re-referenced the cover.
Three specific traps in this block, all of which produced a plausible cover:
OPEN. Stopping at 98 with PositionKnown
FALSE means the arrival tolerance is being applied to an end stop, so the estimate
never recalibrates.STOP must not be a pause. Read the position twice, seconds apart, after a
stop: if it resumes, the target was not dragged to where the cover stood.The position path cannot be driven from a download spec - a spec writes variables, not MQTT - so drive it from the broker and watch the cover's own topics:
mosquitto_pub -h 10.101.1.11 -t Devices/PLC/Lab/In/Covers/fbDoCover002/POSITION -m 35 -q 2
mosquitto_sub -h 10.101.1.11 -v -t 'Devices/PLC/Lab/Out/Covers/fbDoCover002/#' -W 22A healthy run steps in PublishStep increments while travelling and lands on the
exact value when movement ends:
58 CLOSING 53 48 43 38 STOPPED:bulb: The cover subscription is MqttSubCoverPrefix + #. A + would deliver
the command topic and silently swallow /POSITION, one level below it - which looks
exactly like a block that ignores the slider.
The chain, and the one worth understanding before testing:
thermostat OUT → collector THERMOSTAT[n] → VALVE[n] → PUMP → fbPump2 → HEAT_REQUEST → burner
↑ (after ValveCycleTime) (own min run / run-on)
└── PUMP_MIN_ONTIME_ACTIVE ───── MIN_ONTIME_ACTIVE ─┘That feedback arrow is the interlock: while the pump is running out its minimum on-time the collector holds the circuits that were flowing open, so the pump is never left turning against a shut manifold. It has its own spec below.
fbThermostat2 drives circuit 1 (Radiator 1, DO_006), fbThermostat3
drives circuit 2 (Radiator 2, DO_007). Circuits 3-8 are unwired: their valves
stay closed and they announce no Home Assistant entity.
The sensor gate. SensorFault is `NOT SENSOR_VALID OR MEASURED_TEMP <= -50 OR
= 80
, and on a faultOUTis forcedFALSE— deliberately, because the alternative on a heating system is calling for heat forever.SENSOR_VALIDcomes from the 1-Wire multisensor'sDataAvailable AND NOT Error`.
On the bench unit that sensor is not delivering, so all three thermostats sit at
/FAULT TRUE and no MQTT command can make one call for heat. Check first:
Devices/PLC/Lab/Out/HVAC/fbThermostat2/FAULT → must be FALSE to proceedIf it is TRUE, either fix the sensor — the commented-out RegisterDevice in
RS485_INIT is the first place to look — or force it for the test:
{"write": {"GVL_RS485.FB_RS485_1WIRE_MULTISENSOR_01.DataAvailable": "TRUE",
"GVL_RS485.FB_RS485_1WIRE_MULTISENSOR_01.Error": "FALSE",
"GVL_RS485.FB_RS485_1WIRE_MULTISENSOR_01.TEMPERATURE": "18.0"}}Whether that sticks depends on whether the RS485 block writes those outputs every
cycle. Verify it stuck by reading fbThermostat2.SENSOR_VALID back before
concluding anything about the valve.
The pump is slow on purpose. ValveCycleTime is T#3M: the pump only starts
three minutes after heat is first requested, so a valve can open fully before there
is flow. fbPump2 then has its own minimum run and run-on times (2 min / 1 min),
so it will not stop the moment demand goes away. A test that waits two seconds and
reports "pump did not start" is measuring the wrong thing.
That run-on is also why a valve does not close the instant its thermostat is
satisfied. While fbPump2.MIN_ONTIME_ACTIVE is set, the collector holds the
circuits that were flowing open — so a valve still reading TRUE seconds after you
commanded MODE off is the interlock working, not a stuck valve. Read
fbPump2.PUMP before calling it a fault.
Setpoint and mode are PERSISTENT RETAIN, so another thermostat may already be
asking for heat. On the first run here, forcing the sensor healthy immediately put
fbThermostat3 into demand — it still held MODE heat and a setpoint of 18.5 from
a previous session, and 18.0 measured is below that. HeatRequest TRUE before you
have commanded anything is that, not a bug. Read every thermostat's /MODE and
/DESIRED_TEMP before concluding a valve opened on its own.
Do not shorten these timings in the source to speed a test up. It does not work:
an existing FB_init argument changed from a script updates the declaration text
while the compiler keeps reading the old InputAssignments, so the PLC runs the old
value with a clean build and a matching export
(CLAUDE.md has the detail). It is also the wrong place — 5-second
valve travel in source can reach an installation with real pipes.
Write the members at runtime instead, which is what
specs/hvac-fast-chain.json does:
{"write": {"PRG_HVAC.fbPump2Collector.ValveCycleTime": "TIME#5S",
"PRG_HVAC.fbPump2.MIN_ONTIME": "TIME#10S"}}They are plain VAR members that only FB_init assigns, so the write sticks for the
life of the session. Verified: the whole chain then runs in about 15 seconds — valve
open at t+3s, pump and burner by t+17s — against ten minutes at production timings.
Either way, read ValveCycleTime off the PLC and time your waits by what it
actually says. A 3-minute delay mistaken for 5 seconds looks exactly like a dead
pump.
/FAULT must be FALSE.MEASURED_TEMP around 18:Cmd 'HVAC/fbThermostat2/MODE' 'heat'
Cmd 'HVAC/fbThermostat2/DESIRED_TEMP' '22'Out/HVAC/fbThermostat2 → TRUE, /MODE → heat, /DESIRED_TEMP → 22.VALVE[i] := THERMOSTAT[i]
with no delay: Out/HVAC/fbPump2Collector/Valves/VALVE_1 → TRUE.
VALVE_2 must stay FALSE: circuit 2 has its own thermostat, and a valve
opening on its own would be a real bug.ValveCycleTime. Wait past three minutes, then
Out/HVAC/fbPump2 → TRUE. Read fbPump2Collector.PUMP and PumpDelay.Q
too — they tell you whether you are early or actually broken.fbPump2.HEAT_REQUEST → Out/HVAC/fbBurnerGas.Cmd 'HVAC/fbThermostat2/MODE' 'off' → thermostat FALSE.
The pump lingers for its minimum cycle, and VALVE_1 stays TRUE while it
does — the interlock. Both then clear, valve after pump. Confirm they do
rather than assuming, and see the interlock spec below for the version of this
with assertions on it.specs/hvac-valve-pump-interlock.jsonThe one HVAC test that is a regression test rather than a walk through the chain, and the only one that fails on purpose against older code.
./tools/ai/codesys.ps1 download -Force -Address 00E8 `
-Spec .claude/skills/test-plc-logic/specs/hvac-valve-pump-interlock.jsonWhat it pins down: fbPump2 holds its output for MIN_ONTIME after IN drops, so
withdrawing demand does not stop the pump. The collector used to close every valve
in that same cycle, leaving the pump turning against a shut manifold. Step 5 is the
assertion — pump TRUE, bHeatRequest FALSE, and VALVE[1] still TRUE —
and it reads FALSE on the code before PUMP_MIN_ONTIME_ACTIVE was wired.
Three things about it are deliberate and easy to undo by accident:
MIN_ONTIME (20 s) is set longer than ValveCycleTime (5 s). That is the
configuration where the fault appears. At the production values — 2 min against
3 min — the pump stops before the valves finish closing anyway, so the same test
passes on broken code. A test at production timings proves nothing here.VALVE[2] gives the pump a real path and masks
precisely what step 5 looks for.bHeatRequest is
built from THERMOSTAT and not from VALVE, but the test proves that rather than
trusting it.The commanded valve state is what this asserts, which is the honest limit: a bench
has no manifold, so nothing here measures real valve travel. ValveCycleTime
describes a valve opening and no block models how long one takes to shut, so on
real hardware wire the interlock and fit a differential bypass.
Verified 9/9 on the lab PFC200 (00E8), with the broker at 480 retained topics
before and after and nothing in NEW or GONE. The interesting line of the run:
[ok] 5. THE REGRESSION.
fbThermostat2.OUT=FALSE bHeatRequest=FALSE
fbPump2.PUMP=TRUE MIN_ONTIME_ACTIVE=TRUE
fbPump2Collector.PUMP=FALSE PUMP_MIN_ONTIME_ACTIVE=TRUE
fbPump2Collector.VALVE[1]=TRUENo demand, no request from the collector, pump still turning, valve still open.
SENSOR_VALID false mid-demand and confirm OUT drops and
/FAULT goes TRUE. This is the branch that matters most on a heating system
and the easiest to break silently in a refactor.30 to DESIRED_TEMP; expect 24.0 echoed back.VALVE_3..8 from the broker would mean the
startup publish loop broke.The only suite where the bus is the thing under test rather than a device on it. It needs no MQTT at all for the interesting parts, because the bus controller and the transport both publish their state as ordinary outputs.
There is no scope on this bench. FB_RS485_TRANSPORT_RTU's outputs are what you
have instead, and every completed step lands in exactly one of them, so they sum
to the number of steps attempted:
| Counter | Reading it |
|---|---|
Ok | Climbing steadily is the whole point. |
LeadNulls, TrailNulls | Should track Ok almost exactly. This hardware wraps every reply in glitch bytes; that is normal, not a fault. See docs/RS485/UsingModbusRTU_CODESYS3S.md. |
CrcFail | Should be 0. Non-zero with Ok also climbing means marginal wiring, not a code bug. |
NoReply | The slave is absent, at the wrong address, or A/B are swapped. |
BadAddress | A well-formed frame from somebody else, or a mis-framed reply. |
BadEcho | A write was acknowledged with the wrong address or value. Never treated as success. |
Exceptions + LastException | The slave answered and refused. That is a register-map problem, not a wiring one. |
And on FB_RS485_BUSCONTROLLER:
| Output | Reading it |
|---|---|
Transactions | Completed transactions. |
StepsExecuted / Transactions | The batching ratio. Above 1 when a multi-block device like the SDM220 is registered. Exactly 1.0 means batching is not happening — but a falling ratio on a faster bus is normal, not a regression: batching only has something to batch when several of a device's blocks come due in the same grant. Measured 2.7 with a 200 ms task, 1.4 with a 50 ms one. |
Cursor | Must move. Frozen means selection is not advancing. |
Watchdogs | Should stay 0. Non-zero means the transport stopped answering. |
ActiveDevice | -1 when the bus is free. Stuck on one index means a transaction never completed. |
Fairness cannot be seen on a bench with a single slave, because nothing ever competes. Register the same physical meter several times as different logical devices with different polling intervals:
FB_RS485_EASTRON_SDM220_BUSTEST_A : FB_RS485_EASTRON_SDM220_MQTT; // 2s
FB_RS485_EASTRON_SDM220_BUSTEST_B : FB_RS485_EASTRON_SDM220_MQTT; // 7s
FB_RS485_EASTRON_SDM220_BUSTEST_C : FB_RS485_EASTRON_SDM220_MQTT; // 11sall with DeviceAddress := 1, registered after the real devices, called
cyclically in RS485_RUN, and given no FriendlyName and no InitMqtt call
— so they load the bus and publish nothing, which keeps Home Assistant out of it.
Total demand then lands close to the bus's capacity, which is the condition under which unfairness actually shows. The assertion that matters:
every instance has
DataAvailable1/2/3TRUE and a plausibleVOLTAGE.
The one registered last is the one the pre-cursor FOR 0 TO count-1 loop
starved, so BUSTEST_C is the interesting row.
.ai/rs485tx/bench-edits.json and bench-spec.json in the branch that introduced
this are the worked example, with cleanup-edits.json as its exact inverse.
Strip the fixture before shipping — it is a test harness, not project content.
The same fixture plus bench-throughput.json is how bus throughput gets measured:
sample the counters at two known times and divide. Watch the difference between
two windows rather than the totals, because the first window includes the startup
delay. What it has shown so far, five contending devices over the same 30 s:
| 200 ms task, waiting for silence | 200 ms task | 50 ms task | |
|---|---|---|---|
| per step | 1.87 s | 1.30 s | 0.42 s |
| per transaction | 5.00 s | 3.75 s | 0.53 s |
The cost is a fixed number of task cycles per exchange - about 6.5 to 8.5 - so the task period, not the baud rate, is what moves it.
Only testable against a device with a writable register; the Ducobox is the one this project has, and it is not on the bench. The shape of the assertion:
Devices/PLC/Lab/In/RS485/<device>/<node>/write/<reg>.StepsExecuted rises by 2, not 1 — the write and its read-back.AbortOnError skips the
read-back, and nothing is published. That is the case worth proving: the
old code published the payload it had sent as though sending were proof.A discovery change is one of the few things where the broker is a better witness than the PLC. Assert both halves, because they fail independently:
The block thinks it announced. In a download spec:
{"expect": {
"GVL_RS485.FB_RS485_EASTRON_SDM220_1.InitMqttDone": "TRUE",
"GVL_RS485.FB_RS485_EASTRON_SDM220_1.initMqttDiscoveryDone": "TRUE",
"GVL_RS485.FB_RS485_EASTRON_SDM220_1.TopicTruncated": "FALSE"}}TopicTruncated is the one people forget. IEC cuts an over-long CONCAT short
with no error at all, so a long prefix plus a long instance name yields an entity
that simply never appears — and initMqttDiscoveryDone is still TRUE.
The broker actually has it. A -Diff around the download counts the configs:
NEW (19):
+ homeassistant/sensor/..._FB_RS485_EASTRON_SDM220_1_VOLT/config
...Count them against what the block should publish — 14 measurements plus
diag_availability and diag_log is 16 for the SDM220 — and read one payload to
check stat_t against a topic the block really publishes to. A discovery config
pointing at a topic nothing writes is the failure this catches, and it looks
perfect from inside the PLC. The cheapest way to make the two agree is to publish
through PubMqttMessage, which uses the same MQTTPublishTopic the discovery
config advertises, rather than concatenating the prefix and suffix by hand at
every call site.
Read the GONE section before the NEW one. A discovery config that was retained before and is absent now is an entity Home Assistant still shows and nothing publishes to any more.
To check that a device block decodes a register correctly, point a second block at the same register of the same meter and compare readings.
This one is permanent, not a fixture you have to build.
GVL_RS485.FB_RS485_EASTRON_SDM_POWER_1 is registered on the bus against the
lab SDM220 at address 1, declared as an SDM220, alongside
FB_RS485_EASTRON_SDM220_1 reading the same meter. Both publish /ACTP from
register 30013, so the comparison is available on the broker at any time without
touching the project:
./tools/ai/Mqtt-Snapshot.ps1 -Watch -Seconds 60 `
-Topics 'Devices/PLC/Lab/Out/RS485/FB_RS485_EASTRON_SDM220_1/ACTP',
'Devices/PLC/Lab/Out/RS485/FB_RS485_EASTRON_SDM_POWER_1/ACTP':bulb: A near-idle meter publishes exact zeros, and it is not your decode. On
this bench both blocks intermittently reported 0.0 W against ~4.5 W otherwise.
What settles it is watching a different value from the same frame: the SDM220
block reads current, power factor and active power out of one 40-register reply,
and CURR stayed at 0.037 A and POWF at 1.0 on the very cycles where ACTP
read 0. Same frame, same CRC, same decode path — so the zero came from the meter.
Do not chase a decode bug without that control.
serialmode RS485 is per controller. Set from the CODESYS PLC shell, it
reboots the device and survives reboots. Until it is done the bus is silent with
nothing in any counter to show for it — NoReply climbing and everything else
at zero. Set it explicitly even if it already reports RS485.Steady green while the PLC can talk to the broker, blinking red while it cannot —
see
User_leds_CODESYS3S_runtime.md.
Two programs decide it: PRG_PING_DMX pings the broker every 10 s and writes
GVL_MQTT.bBrokerReachable; PRG_MQTT owns the LED and ANDs that with the client's
own MQTT_CONNECTED.
No variable holds an LED's colour. PFC.SetLed is a runtime call, so the only
witness for the light itself is a person at the controller. Say which half you
tested. PRG_MQTT.bLedShowsHealthy is as close as software gets: it is what the code
last wrote to U1, and it is what the change detector compares against.
specs/mqtt-broker-led.json./tools/ai/codesys.ps1 download -Force -Address 00E8 `
-Spec .claude/skills/test-plc-logic/specs/mqtt-broker-led.jsonThree steps, both branches, fully automated, and a regression test on two counts:
PRG_MQTT.stMQTTInfo.MQTT_CONNECTED reads TRUE, and read FALSE for the entire
history of the project before the client's MQTT_INFO output was copied into that
struct. MQTT.MQTT_INFO is an output of the library's MqttClient, not a struct
that populates itself — declare one, read MQTT_CONNECTED off it, and it compiles
perfectly and is FALSE for ever.MQTT_INIT, so its Q could never become TRUE.Verified 3/3 on the lab PFC200 (00E8), broker at 480 retained topics before and
after with nothing in NEW or GONE:
[ok] 2. THE RED BRANCH.
PRG_PING_DMX.sBrokerHost='192.0.2.1' udiBrokerReachable=UDINT#5
PRG_PING_DMX.uiBrokerPingFails=UINT#2 GVL_MQTT.bBrokerReachable=FALSE
PRG_MQTT.bBrokerHealthy=FALSE bLedShowsHealthy=FALSE
PRG_MQTT.stMQTTInfo.MQTT_CONNECTED=TRUE <- session still believed upThat last line is the whole point of the test: the MQTT session reads connected while the host is unreachable, which is exactly what a pulled cable produces.
GVL_MQTT.broker at 192.0.2.1. RFC 5737 reserves it,
so it can never be a host and the ping genuinely fails. Nothing is forced: ICMP, the
debounce, the flag, the health AND, the change detector and the real SetLed call
all run. And because the client ignores a URL change while its socket is up, the
session stays up — which is what makes it a faithful pulled-cable reproduction.GVL_MQTT.bBrokerReachable FALSE. Racy, and it failed that way
once: the Ping task owns that flag and rewrites it every 10 s, so the assertion is
chasing another task's value. Drive the input, not the output.broker at a dead port to break the MQTT session. The write
lands and reads back, and 25 s later MQTT_CONNECTED is still TRUE. The client
does not act on a URL change while its socket is up.To cover ICMP itself — that a real cable pull is detected — pull the cable and watch U1. Confirmed by eye on this bench: red within ~20 s of unplugging, back to steady green within ~10 s of plugging in.
:bulb: SysSockPing returns 1 sometimes against a host that is answering.
0 is success and 5 is unreachable, but a third value shows up intermittently on
healthy hardware — which is why the U3 code has always ignored anything that is
neither, and why the broker ping counts two consecutive failures before it believes
one. A test that asserts on the raw return code will flake; assert on
bBrokerReachable.
Say which mechanism proved each result. "The light responded to MQTT" and "the light's output variable was written" are different claims, and only the first says the subscription works.
If something could not be tested — sensor faulted, no hardware to press, licence expired mid-run — say that plainly instead of narrowing the claim to whatever did pass. An untested path reported as working is worse than no test.
© MichielVanwelsenaere, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
SKILL.md and 6 other files in .claude/skills/test-plc-logic of MichielVanwelsenaere/HomeAutomation.CoDeSys3.
Open the folder on GitHubat commit 924c3da
Test Plc Logic next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Test Plc Logic this skillMichielVanwelsenaere/HomeAutomation.CoDeSys3 | 148 | — | ~8.9k | Automated safety check: Pass | MIT | |
| DOCXrvdbreemen/OTGW-firmware | 207 | 33 repos | ~4.3k | Automated safety check: Pass | Proprietary | |
| PPTXrvdbreemen/OTGW-firmware | 207 | 34 repos | ~2.3k | Automated safety check: Pass | Proprietary | |
| XLSXrvdbreemen/OTGW-firmware | 207 | 35 repos | ~2.9k | Automated safety check: Pass | Proprietary | |
| Sap Extension Creatorheshengtao/super-agent-party | 2.7k | — | ~6k | Automated safety check: Pass | AGPL-3.0 | |
| Ha Frontend Componentshome-assistant/frontend | 5.7k | — | ~1.2k | Automated safety check: Pass | Apache-2.0 |
rvdbreemen/OTGW-firmware
A skill your agent uses whenever the user wants to create, read, edit, or manipulate Word documents (.docx files).
rvdbreemen/OTGW-firmware
Use this skill any time a .pptx file is involved in any way — as input, output, or both.
rvdbreemen/OTGW-firmware
Use this skill any time a spreadsheet file is the primary input or output.
heshengtao/super-agent-party
Create Super Agent Party (SAP) extensions. An agent skill from heshengtao/super-agent-party.
home-assistant/frontend
Home Assistant frontend component patterns. An agent skill from home-assistant/frontend.
home-assistant/android
Home Assistant Android module and layer architecture. An agent skill from home-assistant/android.
MichielVanwelsenaere/HomeAutomation.CoDeSys3
Regenerate the machine-owned parts of the function block docs from the PLCopen export, scaffold a page for a new function block, and check whether the docs still match the code.
MichielVanwelsenaere/HomeAutomation.CoDeSys3
Bring an installation's CODESYS project up to date with this reference project's function blocks, without changing what that installation does.
MichielVanwelsenaere/HomeAutomation.CoDeSys3
Find out why a PFC200 stopped - an application that died, a building that went dead, an exception, a crash, or a restart nobody explained - by reading the runtime's own log and core dump on the…
MichielVanwelsenaere/HomeAutomation.CoDeSys3
Read, write and compile-check CODESYS code in this project without opening the GUI, by driving CODESYS headlessly through its ScriptEngine.
Works with
Exercise the PLC's actual behaviour on real hardware - lights, pushbuttons, covers, dimmers and the HVAC chain - by commanding it over MQTT and asserting the result on the broker and in the running…. CoDeSys3. Exercise the PLC's actual behaviour on real hardware - lights, pushbuttons, covers, dimmers and the HVAC chain - by commanding it over MQTT and asserting the result on the broker and in the running application.
Run `npx skills add MichielVanwelsenaere/HomeAutomation.CoDeSys3 --skill test-plc-logic -a claude-code`. Or copy the skill folder (.claude/skills/test-plc-logic in MichielVanwelsenaere/HomeAutomation.CoDeSys3) into .claude/skills/test-plc-logic in your project. Claude Code loads it when a task matches its description.
Run `npx skills add MichielVanwelsenaere/HomeAutomation.CoDeSys3 --skill test-plc-logic -a codex`. Or copy the skill folder (.claude/skills/test-plc-logic in MichielVanwelsenaere/HomeAutomation.CoDeSys3) into .agents/skills/test-plc-logic in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add MichielVanwelsenaere/HomeAutomation.CoDeSys3 --skill test-plc-logic -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/test-plc-logic, .gemini/skills/test-plc-logic, .github/skills/test-plc-logic and .opencode/skills/test-plc-logic in your project.
SKILL.md names no scripts, command-line tools or credentials: Test Plc Logic is instructions for the agent only.
SKILL.md contains no URLs. Any network use would come from the scripts or tools the agent runs. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Test Plc Logic is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 8.9k tokens (SKILL.md is roughly 36k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Test Plc Logic: DOCX (rvdbreemen/OTGW-firmware, 207 stars), PPTX (rvdbreemen/OTGW-firmware, 207 stars), XLSX (rvdbreemen/OTGW-firmware, 207 stars) and Sap Extension Creator (heshengtao/super-agent-party, 2.7k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
MichielVanwelsenaere (a GitHub user) maintains it in MichielVanwelsenaere/HomeAutomation.CoDeSys3, which has 148 GitHub stars. The repository holds 5 skills in this directory. The repository was last updated on September 14, 2026.
Source: MichielVanwelsenaere/HomeAutomation.CoDeSys3 on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.