--- name: codesys-loop description: Read, write and compile-check CODESYS code in this project without opening the GUI, by driving CODESYS headlessly through its ScriptEngine. Use when adding or changing a function block, refactoring ST code, checking whether a change compiles, re-exporting src/Exports/PLCopen.xml, inspecting the project structure inside the binary .project file, or setting up the toolchain needed for any of that. --- # CODESYS development loop `src/HomeAutomation.project` is a **binary**. It cannot be read, diffed or edited as text. The way in and out is **PLCopen XML**, which CODESYS both exports and imports — and because CODESYS ships a ScriptEngine and runs with `--noUI`, the real compiler can judge the result. Never reason about ST correctness from the XML alone when you can compile it instead. Two entry points, both committed at a normal path so CI and contributors can use them too, not only agents: - `tools/ai/codesys.ps1` — the wrapper you run. - `tools/ai/codesys_task.py` — runs *inside* CODESYS (IronPython 2.7). Don't run it directly. ## Check the toolchain first ```powershell ./tools/ai/codesys.ps1 doctor ``` Fast, does not launch CODESYS. Required: | Item | Notes | |:--|:--| | CODESYS 3.5 SP21 (3.5.21.30) | Includes the ScriptEngine and the IronPython stdlib. No extra license needed for `--noUI` scripting. | | `ScriptEngine.dll` | Under `CODESYS\Common\`. The entire harness depends on it. | | CODESYS Control for PFC200 SL | The project's device. Without it the device will not resolve and the build fails. | | WAGO Device Support Package 2.0.8.9 | Supplies `WagoAppDALI`, which `FB_OUTPUT_DIMMER_DALI_MQTT` needs. Without it the build fails on that block, not just on DALI. It is not vendored — WAGO's licence forbids redistribution — so install it per machine: `docs/WagoPfcPrep.md#installing-the-wago-libraries-dali`. **`WagoAppDALI` is qualified-only**, unlike in e!COCKPIT: its types are `WagoAppDALI.typBallast`, `WagoAppDALI.FbDaliSendDimValue` and so on, so ST lifted out of an e!COCKPIT project does not compile until it is qualified. | | Windows PowerShell 5.1 | The scripts avoid PowerShell 7-only syntax, so either works. | | mosquitto clients | **Optional, and what every runtime check here runs on.** `winget install --id EclipseFoundation.Mosquitto --scope machine`. The installer registers a broker service that is not needed here (leave it stopped) and does **not** add itself to `PATH`; the tooling also looks in `C:\Program Files\mosquitto`. | Override discovery with `-Exe` / `-CodesysProfile`, or set `$env:CODESYS_EXE`. **Python is not needed by this harness** — only by the `update-fb-docs` skill. `doctor` reports it as a warning, not a failure. ### Installing Python (for the docs generator only) `gen_fb_docs.py` needs **3.7+** (it uses f-strings and `from __future__ import annotations`). Install a current release: ```powershell winget install --id Python.Python.3.12 --scope machine ``` Then open a **new** shell and verify: ```powershell py --version ``` Two Windows gotchas worth knowing before you debug a "Python was not found" message: - `winget` installs the python.org build, which provides `python.exe` and the `py` launcher but **no `python3.exe`**. On this machine, invoke the docs generator as `py .claude/skills/update-fb-docs/scripts/gen_fb_docs.py`. - Windows ships an App Execution Alias stub for `python`/`python3` that opens the Microsoft Store instead of running anything. If a bare `python` still opens the Store after installing, turn the aliases off under *Settings → Apps → Advanced app settings → App execution aliases*. `doctor` already ignores anything resolving inside `WindowsApps` for this reason. ## Tasks Each task boots CODESYS, so budget **40–90 seconds**. A `--noUI` process has no usable stdout: results go to `.ai/reports/.json`, with a progress log at `.json.log` that survives a crash. `.ai/` is gitignored. **`.ai/work` is wiped at the start of every `verify` and `simulate`** — it is the sandbox. Never keep anything there. Edit fragments belong in `.ai/edits/`. | Command | Effect | |:--|:--| | `./tools/ai/codesys.ps1 doctor` | Toolchain check. No CODESYS launch. | | `./tools/ai/codesys.ps1 tree` | Dump the project object tree. | | `./tools/ai/codesys.ps1 device` | Report the device tree, the configured gateway, address and simulation flag. Connects to nothing. | | `./tools/ai/codesys.ps1 device -AddModule -Under -Force` | Plug a module into the device tree. Builds first and refuses to save a project that does not build. | | `./tools/ai/codesys.ps1 device -AddDevice -NodeName [-Under ] -Force` | Add a device — a module, or a whole second controller at the project root. | | `./tools/ai/codesys.ps1 device -RemoveNode -Force` | Unplug a device or module. | | `./tools/ai/codesys.ps1 device -MapIo -Force` | Name a terminal's I/O channels, which is what makes a freshly added module readable from IEC code at all. `{ "map_io": [ { "node", "channel", "variable" } ] }`; each mapping is read back and a mismatch is an error. | | `./tools/ai/codesys.ps1 device -RenameNode -NodeName -Force` | Rename a device or module. Re-record the baseline afterwards: a device's name is in every message's object path. | | `./tools/ai/codesys.ps1 scaffold -Scaffold -Force` | Create **or update** GVLs, programs and tasks inside an application, including a task's kind, interval and priority. | | `./tools/ai/codesys.ps1 scan` | List PLCs answering on each gateway. Read-only, needs no project. | | `./tools/ai/codesys.ps1 download -Force` | Full download of the real project to the real PLC, then start it. | | `./tools/ai/codesys.ps1 export` | Rewrite `src/Exports/PLCopen.xml` from the project. | | `./tools/ai/codesys.ps1 verify -Baseline` | Build the untouched project, store its messages as the baseline. | | `./tools/ai/codesys.ps1 verify` | Import `.ai/candidates/*.xml` into a sandbox copy, build, report. | | `./tools/ai/codesys.ps1 simulate` | Download to a simulated PLC and run a test spec. **Currently blocked — see below.** | | `./tools/ai/codesys.ps1 apply -Force` | Import candidates into the **real** project and save. | | `./tools/ai/codesys.ps1 rename -Map -DryRun` | Report what a rename would touch. Writes nothing, builds nothing. | | `./tools/ai/codesys.ps1 rename -Map -Force` | Rename objects and identifiers, rewriting every reference. Refuses to save unless it builds. | | `./tools/ai/codesys.ps1 probe` | Dump real .NET signatures of the scripting API. | | `./tools/ai/codesys.ps1 info` | Read-only: IDE version, libraries, devices, and a hash of every object's code. | | `./tools/ai/codesys.ps1 compare -Project A -Against B` | CODESYS's own object-level diff between two projects. **Broken on 3.5.21.30**: `compare_to` rejects the flags it is given with *"Cannot convert numeric value 5 to ComparisonFlags. The value must be zero."* Diff two `export`s instead. | | `./tools/ai/codesys.ps1 libs` | Read-only: every library reference against every version installed on this machine. | | `./tools/ai/codesys.ps1 libs -RemoveLib '#Name'` | Drop a library reference. Builds first, refuses to save if it does not build. | ### Working on another project `-Project ` points any task at a different `.project` file — that is how `sync-implementation-project` drives a building's installation project. Reports are then stem-qualified (`baseline.SiteA.json`), so a foreign build cannot overwrite this project's baseline. `download` **refuses** `-Project`. Four companions to it: | Flag | Why | |:--|:--| | `-Candidates ` | A separate candidate set, so two pieces of work cannot import into each other's project. | | `-Only ` | Export just some objects. Errors by name if one is not found rather than exporting less than asked. | | `-ImportFolders` | Honour the folder structure in a candidate exported from another project. Without it the block lands at the project root, is never compiled, and the build stays green while the import achieved nothing. | | `-ImportConflict replace` | Replace an object that already exists. Without a policy the importer has no say and files a **second** object of the same name, leaving the original compiled. | The last two fail the same way — silently, with a green build — and a candidate lifted out of another project needs both. ## Workflow 1. **Confirm the export is current** before trusting it: ``` git log -1 --format=%cd -- src/Exports/PLCopen.xml src/HomeAutomation.project ``` If the project is newer, run `export` first. Regenerating docs or reasoning about pins from a stale export bakes in wrong interfaces. 2. **Record a baseline** once per session: `verify -Baseline`. The project builds with 9 pre-existing warnings — three OSCAT `CONSTANTS_SETUP` string-length warnings, three `IP_CONTROL2` sign conversions, `GVL_PERSISTENT`, one genuine sign conversion in `PRG_DMX_SEND` line 70, and one for `PRG_DALI_VERIFY.Dimmer.DimValue` having no persistent list on `Wago_PFC200_G2_Virtual`. That last one is **load-bearing**: it is only reported because the DALI block really is compiled there, so if it ever disappears, the verification application has stopped verifying. The application is never downloaded, so nothing needs retaining. With a baseline recorded, `verify` prints a `NEW vs baseline` section so your own changes stand out. Re-record after a library or CODESYS version change. 3. **Read** the current code from `src/Exports/PLCopen.xml`: declarations, ST bodies, methods and the task configuration are all there. 4. **Write** the change as a single-block PLCopen file in `.ai/candidates/`. Start from `tools/ai/templates/FB_TEMPLATE.xml`, which documents the dialect. For anything non-trivial, copy the shape of a comparable existing block out of `PLCopen.xml` rather than inventing structure. 5. **Verify** — `./tools/ai/codesys.ps1 verify`. This copies the project to `.ai/work`, imports the candidates, builds, and reports every message with object, line and column. Iterate here; the real project is untouched. Read the `harness` section of the report, not just the result line (see below). 6. **Apply** only once verify is clean: `apply -Force`. It refuses to save if the build fails. Then **re-export** so the XML matches the binary again, and update the docs with the `update-fb-docs` skill. Steps 6 onwards change tracked files, including a binary. Ask before running them unless the user has already said to land the change. ## Changing an existing POU: use edits, not candidates **A candidate XML import REPLACES the whole object**, so it is right for a *new* block and wrong for changing an existing one — a partial file silently drops every method it omits, and appending to an existing POU's plaintext declaration is ignored outright. For existing code use `.ai/edits/edits.json`, which drives the ScriptEngine's textual API and is applied by both `verify` and `apply`: ```json { "edits": [ { "pou": "FB_MQTT_BASE", "decl_append_file": "base.decl", "skip_if_contains": "FriendlyName" }, { "pou": "FB_OUTPUT_BINARY_MQTT", "body_prepend_file": "prologue.st", "skip_if_contains": "self-wiring prologue" }, { "pou": "PRG_MAIN", "decl_replace_file": "main.decl" }, { "pou": "PRG_MAIN", "member": "MAIN_INIT", "body_replace_file": "main_init.st" } ] } ``` | Key | Effect | |:--|:--| | `pou` | POU, program **or GVL** name. Must resolve to exactly one object that owns text. | | `path` | Substring of the object path, matched case-insensitively, to disambiguate `pou`. | | `member` | Target a method or action instead of the POU itself. | | `decl_append` / `decl_replace` | Declaration, inline text or `*_file`. | | `body_prepend` / `body_append` / `body_replace` | Implementation, same. | | `skip_if_contains` | **Idempotence sentinel.** If the target text already contains it, that append/prepend is skipped. | Notes that cost real time to rediscover: - **Always set `skip_if_contains` on an append or prepend.** An edit spec gets re-run constantly during a refactor, and without the sentinel a second run duplicates the text. `*_replace` needs no guard. - `*_file` paths resolve relative to the spec file, then to the repo root. Keep fragments in `.ai/edits/` — **never `.ai/work`, which `verify` deletes.** - **A name stops identifying a POU as soon as a project has two controllers.** An installation with two PFCs has two `PRG_MAIN`, both real, both owning text, and every edit addressed by name alone is then refused as ambiguous. `"path": "Wago_G1_Annex/"` picks one. The refusal is the useful behaviour — the alternative is a coin flip over which building gets rewritten — so do not work around it by renaming a program. `delete_pou` has no such qualifier yet; it will report the same ambiguity and stop. - `insert()` takes the **offset first**, the reverse of the shipped stub. Same trap as `export_xml`. - **`create_method` works on an INTERFACE too**, which is how a method reaches `I_RS485_DEVICE` or `I_RS485_TRANSPORT` without hand-authoring interface XML: the interface object owns text, so `create_method` plus `decl_replace` is the whole job. Every implementer then needs the same method or the build fails by name — which is the useful failure, and how the six RS485 device blocks were kept in step when `GetCommissioning` was added. - **A `///` doc comment written by `decl_replace` does not reach the export.** CODESYS only materialises `` for a comment its own editor parsed, so a method created from a script exports with its plaintext declaration intact and no structured documentation — and `update-fb-docs` therefore shows it as `_TODO: describe this._` however carefully the fragment was commented. Write the description in the doc page, or in the generator's `GLOSSARY` when the method means the same thing on every block. - `-Edits none` skips the spec, which is how you build the committed project standalone as a control. A `verify -Baseline` never applies edits. - Generating the fragments from a script (see `.ai/edits/gen.ps1` in history) beats hand-writing 14 near-identical prologues. ## Renaming: `rename`, not edits Renaming is its own task because `node.rename()` renames an object and updates **nothing** that refers to it — CODESYS's IDE refactoring is not exposed to the ScriptEngine. The references are the harness's job: ```powershell ./tools/ai/codesys.ps1 rename -Map tools/ai/rename/179-objects.json -DryRun ./tools/ai/codesys.ps1 rename -Map tools/ai/rename/179-objects.json -Force ``` The map holds `objects` (a type, block, program or GVL — the name is project-unique, so every occurrence of the token is that object) and `identifiers` (variables inside one declaring object). An identifier group's `mode` decides how far it reaches: | mode | rewrites | right for | |:--|:--|:--| | `local` | the declaring object and its methods and actions | a program's own instances, a block's internals | | `qualified` | also `Owner.name` project-wide | GVL members, enumeration values | | `loose` | also `.name` and `name :=` project-wide | a function block's pins, whose qualifier is an instance name nothing can enumerate | Five things learned building it, all of which cost a run: - **A program rename is two renames.** The task configuration calls a program through a separate object that carries the program's name and owns no code, so every text-owning filter misses it. Renaming the program alone gives `Identifier 'PLC_PRG_MAIN' not defined <.../Task Configuration/MainTask>`. The task now renames the call twin too, and reports it as `(task call)`. - **One name can mean several objects.** Two applications mean two `MqttVariables`, and `FB_MQTT_BASE` declares `STRING(GVL_MQTT.MQTT_TOPIC_LEN)`, so the name has to resolve in both. Ambiguity is refused unless the map says `{"new": ..., "all": true}` — or `"path"` to pick one. - **A shadow is the one mistake that compiles.** `PRG_DALI_VERIFY` declared an instance called `Dimmer` while the enumeration `Dimmer` was becoming `E_DIMMER`; a blind sweep renames the instance too, consistently, and the build stays clean with a variable now called `E_DIMMER`. The task refuses an object rename whose old name is also declared as a variable, and identifier groups run **first** so one map can move the variable out of the way. - **Dry-run counts are an upper bound.** Passes do not compose without writing, so a map that renames both a variable and an object of the same name double-counts: 515 references in `-DryRun` versus 473 actually rewritten. - **String literals are never touched, comments always are.** Topics, discovery keys and JSON live in literals; a comment still naming the old object is worse than no comment. Afterwards: `verify -Baseline` (object paths moved, so the old baseline reads every unchanged warning as NEW), `export`, then `update-fb-docs`. The docs generator keys the GVL region on the list's name, so a GVL rename means editing `gen_fb_docs.py` too. ## Authoring candidates: what the importer actually accepts Established by compile probe, not by reading documentation: - **A hand-written candidate CAN file itself into a folder.** The skill's own advice used to be that a candidate with no folders lands at the project root, which is true only because nobody had put folders in one. Add a `projectstructure` `` block naming the target folder and the object, **with no `ObjectId` attributes at all**, and the default import path honours it - the report then reads `added: BASIC/FB_YOUR_NAME` rather than a bare name. Worth doing: folder placement is cosmetic to the compiler, but there is no scripted way to move an object afterwards, so the alternative is IDE hand-work. The methods are listed as nested ``s; the import adds each and reports it. `FB_INPUT_TEMPERATURE_RTD_MQTT` landed in `BASIC/` this way. - **Plaintext declarations work for a NEW POU, but do not reliably override an existing one.** Authoring a brand-new block with a plaintext declaration and an empty `` works (proven). But taking an existing POU out of the export, appending a `VAR_INPUT` block to its plaintext declaration and re-importing it does **not** apply the new members: the block keeps exactly its old declaration, the import still reports `replaced 1`, and the only symptom is `Identifier 'X' not defined` in whatever referenced the new member. Emptying the structured `` did not change this. **Unresolved** — the next attempt should isolate it by (a) removing the `` element entirely rather than emptying it, (b) trying a plain `INT` member to rule out the enum type, and (c) comparing against editing the structured `` instead, which is the safer default until this is understood. Prefer the ScriptEngine textual API (`textual_declaration.append`) for editing an existing POU's declaration; that path is already proven by the verify harness, which injects into a real program this way on every run. - **A `` takes `` then ``, and nothing else.** There is no `` child on a DUT in `tc6_0200`; adding one fails the whole file with The element 'dataType' ... has invalid child element 'addData' which names the wrong element and reads like an ordering problem, so the obvious fix — shuffling the children — does not help. Put a type's prose in the per-value `enumvaluedocumentation` block instead. **The import failure is easy to miss**: `verify` reports it in `imports[].errors`, then the build fails with a hundred `Identifier not defined` errors for the type, and the real message is the first one, not the hundred. `E_RELAY_TYPE` was authored this way — copy it. - For a NEW POU, put the plaintext declaration in an `` block on the ``: FUNCTION_BLOCK FB_X EXTENDS FB_MQTT_BASE VAR x : INT; END_VAR The importer prefers it over the structured ``, it is lossless, and `EXTENDS` works. Get a template by running `export -Plaintext`, which writes `.ai/reports/PLCopen.plaintext.xml` with every POU in this form. - **A METHOD's interface must be structured XML.** A nested `InterfaceAsPlainText` inside a `` is silently ignored, so the method is built from the structured `` instead — which produced a malformed `FB_init` that failed in four different ways at once. For `FB_init` that means `` plus `bInitRetains`, `bInCopyCode` and your own parameters as ``. - **`qualified_only` is set on every GVL here** (`GVL_MQTT`, `GVL_DALI`, `GVL_PERSISTENT`, `GVL_DMX`, `GVL_RS485`), so a POU body must write `GVL_MQTT.fbMqttPublishQueue`, never the bare name. ### FB_init and inheritance, as this compiler actually behaves - A **derived** FB that declares no `FB_init` of its own still accepts the **base's** `FB_init` parameters at the declaration site: `inst : FB_DERIVED(sFriendlyName := 'Kitchen');`. Declare `FB_init` once on the base and the whole hierarchy inherits the parameter. - `FB_init` may read a GVL value and may take `ADR()` of a GVL member. It compiles — but *runtime* initialisation order between a GVL and an instance in another POU is not something the compiler checks. Prefer storing only literals passed into `FB_init` and doing GVL-dependent wiring lazily on the first cycle, which is order-proof. - Precedent worth copying: `GVL_MQTT.PLC_Device` is declared inside the GVL with a full `FB_init` argument list including `pMqttPublishQueue := ADR(fbMqttPublishQueue)`. ## Instantiating a block the harness cannot Auto-instantiation cannot declare a block whose `FB_init` takes parameters. Two optional files let you take over: | File | Effect | |:--|:--| | `.ai/candidates/_harness.decl` | Appended verbatim to the host program's declaration. Give a complete `VAR ... END_VAR` block. | | `.ai/candidates/_harness.impl` | Appended verbatim to a body, so you can actually call the block. | Any candidate block named in these files is counted as covered and drops out of `not_instantiated`. Note the programs here keep their logic in **actions**, so a program's own body is often empty and exposes no implementation; the harness falls back to the first action that has one and reports it as `impl_host`. **Delete them when the work they supported is finished.** They are gitignored and machine-local, they are injected into *every* `verify` from then on, and nothing reports that they exist. Two ways that bites: - **`verify` compiles more than the project does.** A harness left over from an old refactor keeps an otherwise-unreferenced block compiled on your machine only. Your `verify` is green, a colleague's or CI's is green for a different reason, and the shipped project never compiles that block at all. `apply` injects no harness, so it is the honest answer to "is this block compiled?" - **They break the next refactor and blame the wrong thing.** A leftover harness calling `InitRS485(...)` failed a run that had just moved that configuration into `FB_init` - four errors pointing at a program nobody had touched, in code that is not in the repository. ## Library references `libs` is the way in and out of the Library Manager, which is otherwise only reachable from the IDE. ```powershell ./tools/ai/codesys.ps1 libs # what is referenced, and what is installed ./tools/ai/codesys.ps1 libs -LibFilter modbus # also: every installed Modbus library ./tools/ai/codesys.ps1 libs -RemoveLib '#IoDrvModbus' ./tools/ai/codesys.ps1 libs -AddLib 'SysCom, 3.5.17.0 (System)' ./tools/ai/codesys.ps1 libs -UpdateLib 'PRO_JSON' # repoint a PLACEHOLDER at the newest installed ``` Read-only without `-RemoveLib` / `-AddLib` / `-UpdateLib`. With any of them it behaves like `apply`: it builds first and **refuses to save a project that does not build**, which is what makes "is anything still using this?" a question you can answer by trying it. Four things about the report are worth knowing before acting on it: - **`-LibFilter` only sees this machine.** The repository query lists what is *installed*, not what the CODESYS Store has. "Nothing newer" means "none here", never "none exists" — installing a newer library is still an IDE job. - **A `*` version floats.** `PRO_JSON, * (Pro Electric)` already resolves to the newest installed version, so it is never reported as behind one. What pins it to something older is a *redirection*, which the report shows separately. - **`(not resolved in this project)` is not `outdated`.** Several visualisation placeholders carry a default resolution but resolve to nothing, because nothing uses them. Reporting those as outdated would send you after a version no build is reading. - **Names must match the Library Manager exactly**, `#` included for a placeholder. `libs` with no arguments prints the exact strings. `-UpdateLib` takes a **placeholder** name only. A fixed reference carries its version inside its name, so moving one means `-RemoveLib` then `-AddLib`. ### The repository global is called `librarymanager` Not `library_manager`, which is what the shipped `.pyi` stub says and what does not resolve — the same class of stub-versus-reality gap as `export_xml`'s argument order and `insert()`'s parameter order. `repository_manager()` in `codesys_task.py` tries the names and then falls back to finding whatever object answers `get_all_libraries`, so this should not need rediscovering. Also: a repository entry's `displayname` is the **full** `"IoDrvModbus, 4.5.0.0 (CODESYS)"` string, not a bare name. Grouping on it directly yields one entry per version and matches nothing. ## The device tree `device -AddModule` plugs a K-bus module in, which was the last part of the project only the IDE could reach: ```powershell ./tools/ai/codesys.ps1 device # read-only: the tree ./tools/ai/codesys.ps1 device -AddModule '0287_75x_647' -Under 'Pfc200Bus' -Force ``` A whole device, including a second controller, goes in the same way — but a device carries its own identification, so it has to be given: ```powershell # type:id:version, read out of the device description ./tools/ai/codesys.ps1 device -AddDevice '4096:1006 1209:6.4.5.11' -NodeName 'Wago_PFC200_G2_Virtual' -Force ./tools/ai/codesys.ps1 device -AddDevice '32776:07530647000000002424:2.0.0.20' -Under 'Kbus' -NodeName 'DALI_753_647' -Force ./tools/ai/codesys.ps1 device -RemoveNode '_75x_647' -Force # and back out again ``` **A second controller is how an otherwise-uncompiled block gets covered.** A project can hold several devices, and an application that is never downloaded is still compiled. Adding a controller creates `Plc Logic/Application` with a Library Manager and nothing else, so `scaffold` fills it in: ```powershell ./tools/ai/codesys.ps1 scaffold -Scaffold tools/ai/scaffold/g2-dali-verify.json -Force ``` A scaffold spec creates GVLs, programs and tasks inside a named application, idempotently by name, and it takes `*_file` fragments exactly as an edits spec does. `Wago_PFC200_G2_Virtual` in this project is the worked example. A task item takes `kind`, `interval`, `priority` and `calls`, and an item naming a task that already exists **updates** it — which is how an existing task's scheduling is changed without the GUI. `tools/ai/scaffold/g1-ping-priority.json` is the priority example and `tools/ai/scaffold/mqtt-task-cyclic.json` the `kind` one, taking `MqttCommunication` from Freewheeling to Cyclic. Omitted fields are left alone. `kind` is set **before** `interval`, because a task that is still Freewheeling has no period to set and CODESYS may ignore or reject one; and it is read back afterwards, with a mismatch reported as an error. Which ScriptEngine global holds the task kinds is undocumented and `dir()` reveals nothing, so `task_kind_candidates` tries every plausible container and spelling and lets the read-back decide — the same shape as a scaffold item's `"set"`. Four things learned building that, each of which cost a run: - **A pool POU still needs an instance.** Landing a block in the project is not the same as getting it compiled; see *A clean build does not always mean checked* below. That is the whole reason the second controller exists. - **Task priority is capped per device.** The WAGO G2 description sets `maxtaskpriority` to 15, and CODESYS rejects anything higher with *"The task priority is invalid"* without mentioning a range. Read the limit from `` in the device description. - **Libraries resolve from the project-level Library Manager.** A program in a second application referenced `WagoAppDALI` and `FB_OUTPUT_DIMMER_DALI_MQTT` with no library reference of its own and compiled. What does *not* carry over is a GVL: those belong to an application, so the second one needs its own `GVL_MQTT` — only the two constants `FB_MQTT_BASE` actually reads. - **`dir()` on a ScriptEngine object returns nothing**, so the usual way of finding an undocumented member does not work here. `probe` dumps it anyway, and it comes back empty; a name has to be tried and read back instead. That is what a scaffold item's `"set"` is for. Three things about `-AddModule`, all of which cost a run to learn: - **A module is not identified the way a device is.** `add` takes the *parent's* `(type, id, version)` plus the module's own `ModuleId`, which is why every module under `Pfc200Bus` reports the bus's identification (`288`, `0000 0001`, `4.19.0.0`) and differs only in name. The harness therefore reads the identification off the parent instead of asking for it — there is exactly one right answer and it is already in the project. - **ModuleIds come from the device description**, not from a catalogue the API exposes. For this project's bus that file is `C:\ProgramData\CODESYS\Devices\288\0000 0001\4.19.0.0\device.xml`, or the copy under `CODESYS Control for PFC200 SL\...\WagoPFC200Internalbus.devdesc.xml`; grep it for ``. `0287_75x_647` is the 753-647 DALI multi-master. - **`-Under` resolves in three tiers: full path, exact name, path substring.** Without the last two being ranked, `Pfc200Bus` is ambiguous by way of its own children, whose *paths* all contain it — the obvious query refused by the modules already on the bus. The path tier is there because an exact name stops identifying anything the moment a project has two controllers: an installation with two PFCs has two nodes called `Pfc200Bus`, both real. A substring does not rescue that either, since `Wago_G1_Annex/Pfc200Bus` is a substring of every module already on that bus. Only the whole path is unique, so pass the whole path — `-Under 'Wago_G1_Annex/Pfc200Bus'` — on any project with more than one controller. Writing needs `-Force`, and like `libs` it builds first and refuses to save a project that does not build. ### Two things a second application broke, and how they are fixed Both were latent for as long as the project had exactly one application, and both made the harness quietly report less than it had checked: - **`build()` is incremental, `rebuild()` is not.** An application CODESYS considers up to date compiles nothing and says nothing, while still landing in `built`. A baseline recorded straight after a save therefore came back with the new application's messages only. `build_and_collect` now calls `rebuild()`. - **A build CLEARS the message store.** Sweeping once after building every application returned the *last* one's messages and dropped the rest — which is how a baseline came back with none of this project's eight known warnings. Messages are now collected after each application and accumulated. The second fix also surfaced the library `Information` messages (MQTT's `TODO` and `semaphore` notes) that had been swallowed all along. They are in the baseline now, so they do not read as new. ## A clean build does not always mean checked **An unreferenced POU is never compiled.** A block imported into the project-level POU pool belongs to no application, so CODESYS generates no code for it and reports a successful build for code that does not compile. This is the most dangerous failure mode in the whole loop. `verify` works around it by declaring an instance of each candidate function block inside a program the task configuration already calls, which forces a full check of the body. Two cases cannot be handled that way and are reported instead: - a `pouType` other than `functionBlock` (a function or program is only compiled where it is called), and - an `FB_init` that takes parameters, since the instance cannot be declared without supplying them. Those appear as `NOT COMPILE-CHECKED` in the console and under `harness.not_instantiated` in the report, and the result line degrades to `BUILD OK, but N candidate block(s) were not compile-checked`. **Treat that as unverified.** Exercise such a block from a real call site instead. Note that `apply` injects no harness at all, by design — verify before you apply. ## Running the application — what is and isn't possible **Compile-checking is the automated gate. Behavioural testing needs real hardware.** Don't rediscover this the hard way: `simulate` is implemented and mechanically works — it enables simulation on a sandbox copy, builds, and would log in, start, and run a JSON test spec. But this project cannot run in simulation. Enabling simulation retargets the application from the PFC200's 32-bit ARM runtime to the in-process Windows simulation runtime, which is 64-bit, and the vendored `SysFile23` / `SysSocket23` compatibility libraries that the MQTT library needs are 32-bit only: The Library 'syssocket23, 3.5.13.0 (system)' is only supported in 32 bit applications There is no 32-bit IDE in this install, and the one Windows runtime present is the **x64** `CODESYS Control Win V3` service, so nothing here can host a 32-bit application. The task is kept for the diagnosis and in case the library situation changes. `scan` relies on a gateway broadcast and is not reliable — it listed three PLCs once and nothing at all twenty minutes later, on a machine with two NICs on the same subnet. When it comes up empty, find the PLC out-of-band instead: `arp -a` filtered on WAGO's `00-30-de` MAC prefix maps IPs to the device names the CODESYS scan reports, and `Test-NetConnection -Port 11740` confirms the runtime is listening. Then use `download -Ip `, which resolves the node through the gateway directly and needs no broadcast. The spec format, for when it is usable — or as the model for a device-side test: ```json { "steps": [ { "label": "press", "write": {"GVL_MQTT.clientID": "'test'"}, "delay_ms": 300 }, { "label": "assert", "expect": {"GVL_MQTT.clientID": "test"} } ] } ``` Run with `./tools/ai/codesys.ps1 simulate -Spec path\to\spec.json`. Values are strings in both directions, and `download -Spec` runs the same format against real hardware. A failed `expect` lands in `test_failures` and fails the run, distinct from a compiler error. **An `expect` is compared against the value as the runtime prints it, so it needs the type prefix and, for a string, the apostrophes:** `INT#4`, `UDINT#1`, `TIME#10s`, `'probes=1 found=9600/255'`. Writing `4` against an `INT` reports `expected 4, got INT#4`, which reads as a behavioural failure and is nothing of the kind — and each retry is a full download, so it costs three minutes to learn twice. ## Verifying runtime behaviour over MQTT The compiler is the only automated gate on the PLC side, but **the broker sees everything the PLC publishes** — which is where the runtime checks live. Use them around every download: ```powershell ./tools/ai/Mqtt-Snapshot.ps1 -Out .ai/mqtt/before.txt # BEFORE the download ./tools/ai/codesys.ps1 download -Force -Ip 10.101.1.232 ./tools/ai/Mqtt-Snapshot.ps1 -Out .ai/mqtt/after.txt # after, once settled ./tools/ai/Mqtt-Snapshot.ps1 -Diff .ai/mqtt/before.txt,.ai/mqtt/after.txt ``` The snapshot captures **retained** topics only (`--retained-only`), so it is reproducible rather than a race: discovery configs and last-known states survive a reconnect. Topics are sorted, so two snapshots diff cleanly. `-Watch` prints live traffic instead, for checking that a pushbutton event actually publishes. Read the diff's **GONE** section first. A discovery config that was retained before and is absent now means an entity Home Assistant still shows and nothing publishes to any more — exactly the silent regression an `EntityType` mistake causes, and exactly what a clean compile cannot tell you. ### Did the discovery configs actually get republished? A snapshot diff cannot answer that, and the difference matters: a retained config looks identical whether the PLC wrote it a second ago or a year ago. So a broken publish path is invisible to a diff — the broker stays fully populated with stale configs while the PLC announces nothing. That is not hypothetical; it is what a `RIGHT()` on a `STRING(1500)` did to every entity on the lab PLC for weeks. `check_mqtt_discovery.py` answers both halves. It validates the structure of every config (valid JSON, not truncated, `uniq_id` present, unique, and matching its own topic, a device with ids, something to talk to), and it separates a genuine publish from the retained backlog using the **retain flag** — live traffic arrives with retain=0, the broker's replay with retain=1. ```powershell # 1. record what Home Assistant currently knows py tools/ai/check_mqtt_discovery.py --device Wago_PFC200_G1_Lab \ --snapshot .ai/mqtt/expect.json # 2. watch, and download while it runs (background one of them) py tools/ai/check_mqtt_discovery.py --watch 240 --expect .ai/mqtt/expect.json \ --device Wago_PFC200_G1_Lab # structure only, against whatever is retained now - needs no PLC py tools/ai/check_mqtt_discovery.py ``` It also fails on a `composed truncated json` / `failed json root strip` / `had empty MqttJSON` line appearing live on the diagnostic log topic, because that is how the PLC reports a config it refused to publish — and nothing else reads it. **`%r` is the retain flag; `%R` is the response topic.** Getting that wrong makes every message parse as a live publish, so a watch against a powered-down PLC reports full coverage and passes. The tool now refuses a flag that is not `0` or `1` rather than guessing. Broker for this project is `10.101.1.11:1883` (`GVL_MQTT.broker`), and the trees worth watching are `homeassistant/#` and `Devices/PLC/Lab/#` (`GVL_MQTT.MqttBaseTopic`). Credentials via `-User` / `-Password` if the broker needs them. ## Downloading to the real PLC `download` performs a **full download of `src/HomeAutomation.project`** to the physical PLC and starts it. A download stops the running application and re-initialises non-persistent variables, and this project drives HVAC, a burner, covers and lights. So: - `-Force` is required. Without it the task refuses and explains why. - It **never saves the project** — no run can leave the binary altered. - It **refuses to run if the device is in simulation mode**, which would otherwise mean downloading into a simulated PLC while reporting success. - It builds first and aborts if the build is not clean. - On success it logs out but leaves the PLC **running**; only the connection closes. `-NoStart` loads without starting, `-BootApplication` also writes a boot application so the PLC comes back up running this project. - **Confirm the target with the user every time.** Bench versus live is not something to infer from a device name. ```powershell ./tools/ai/codesys.ps1 scan # what is actually reachable ./tools/ai/codesys.ps1 download -Force -Address 003E # target one node, this run only ``` **The stored address goes stale.** The committed project points at node `00E8`, which no longer answers; a scan found three PFC200s on `003C`–`003E`. Run `scan` first and check the runtime column — only a device reporting *CODESYS Control for PFC200 SL* matches this project; the WAGO 750-8202 entries run a different runtime. `-Address` (node, as `scan` prints it) and `-Ip` retarget for a single run without touching the committed settings. **Credentials** come from `$env:PLC_USER` / `$env:PLC_PASS` when set, otherwise CODESYS's cached device login is used. They are passed through the task file in gitignored `.ai/`, which the wrapper shreds immediately after the run. Never put a device password in a committed file. ## Report format `.ai/reports/.json`: | Key | Meaning | |:--|:--| | `ok` | No tool errors and no compiler errors. | | `messages[]` | `severity`, `text`, `object`, `position`, `number`, `occurrences`. | | `error_count` / `warning_count` | Counts over `messages`. | | `imports[]` | Per candidate file: `added`, `replaced`, `skipped`, `errors`. | | `harness` | `host`, `instantiated`, `not_instantiated` — read this. | | `built` | Applications that compiled. | | `errors[]` | Tool-level failures, distinct from compiler messages. | | `test_failures[]` | Failed `expect` assertions from a `simulate` spec. | | `online` | `simulate` only: login state, application/operating state, per-step results. | | `devices` / `gateways` | `device` only: configured target and known gateways. | `occurrences` counts identical messages collapsed across message categories, so a mismatch against CODESYS's own "N errors, M warnings" line is explainable rather than a sign that something was hidden. ## Scripting API traps - **A script cannot position a module on a bus, and cannot see where one sits.** Three separate attempts, so nobody repeats them: `insert(name, index, ...)` is the order the binding accepts (index-first is refused) and it **ignores the index** and appends; removing a terminal and re-adding it does **not** move it either - the saved project reloads in exactly the order it went in; and the order `get_children(False)` reports is **creation order**, not rail position. The PLCopen export's `ProjectStructure` agrees with `get_children`, so it is not independent confirmation of anything. The trap this sets is the expensive part. The IDE showed three RTD terminals side by side while both scripted views reported them split apart by two other modules, and a guard built on the scripted view **refused a correct project**. Position on a K-bus decides which process-image words a terminal reads, so getting it wrong is silent and serious - but it can only be checked and fixed in the IDE's device tree. `add_module` now prints an ADVISORY saying so whenever an index is asked for, and reports the creation order labelled as such. Adding a terminal at the far right of the rail avoids the question entirely. - **I/O channel mapping IS scriptable**, which the skill assumed it was not. A terminal's channels live on its *child connector's* `host_parameters`, not on `device_parameters`, and each one that answers `is_mappable_io` carries an `io_mapping` whose `variable` setter names it - an unqualified name creates the variable, exactly as typing one into the IDE's mapping editor does. `device -MapIo ` does it in bulk. Read the value back afterwards: the setter takes any expression that parses, so a typo is accepted quietly. - **The shipped `.pyi` stubs have wrong argument orders.** `export_xml` and `import_xml` really take `reporter` **first**, and the .NET binding rejects those names as keywords — pass them positionally. - **Reporter classes must subclass** the injected `ImportReporter` / `ExportReporter`. A duck-typed class is refused with `expected IExportReporter`. - **Folders are not exportable.** Passing one as an export root makes CODESYS skip its entire subtree, which silently drops every function block. The driver walks through folders and passes leaf objects. - **IronPython 2.7, not CPython.** No f-strings; its `json` refuses .NET integers and ASCII-encodes text, which non-ASCII compiler messages break. Everything entering the report goes through the `u()` helper. - Settle any further API question with `probe`, which dumps the real .NET overloads, rather than guessing across 80-second round trips. ## Export fidelity Re-exporting an unchanged project reproduces the committed `src/Exports/PLCopen.xml` byte for byte **except two header timestamps**, so expect a 2-line diff and nothing more. A larger diff means the project really changed. Object order is sorted deliberately — with `_` folded below alphanumerics, matching the order CODESYS itself produced — so the file stays diffable.